diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000000..4481ebc6ba3 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,41 @@ +nextest-version = "0.9.136" +# The PostgreSQL lane uses a run-scoped desired-state template database and a +# per-test wrapper, both of which require nextest's script support. +experimental = ["setup-scripts", "wrapper-scripts"] + +[scripts.setup.postgres-template] +# Bootstrap the desired-state source database once per nextest invocation. +command = { command-line = "scripts/postgres-test-setup.sh", relative-to = "workspace-root" } +slow-timeout = "60s" + +[scripts.wrapper.postgres-isolation] +# Clone or create a unique database for each test process, then drop it on exit. +command = { command-line = "scripts/postgres-test-wrapper.sh", relative-to = "workspace-root" } + +[profile.postgres-ci] +# This structural convention keeps new PostgreSQL-backed tests discoverable +# without maintaining an exact list of test names. +default-filter = """ +(test(/postgres_tests::/) or binary(/^postgres_/)) +and not test(/(^|::)external_infra[^:]*::/) +""" +fail-fast = false +# Eight workers was the fastest stable setting in the Blox benchmark while the +# wrapper retained one database per concurrently running test process. +test-threads = 8 + +[test-groups.postgres-cluster-global] +# These tests inspect cluster-wide activity or create least-privilege sessions, +# so database-per-test isolation alone cannot make them independent. +max-threads = 1 + +[[profile.postgres-ci.overrides]] +filter = "test(/cluster_global_/)" +test-group = "postgres-cluster-global" + +[[profile.postgres-ci.scripts]] +# Script filters are separate from default-filter: they attach the setup and +# isolation wrapper to the same automatically discovered test set. +filter = "(test(/postgres_tests::/) or binary(/^postgres_/)) and not test(/(^|::)external_infra[^:]*::/)" +setup = "postgres-template" +run-wrapper = "postgres-isolation" diff --git a/.env.example b/.env.example index 02e907b8cfd..6d127382479 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,21 @@ REDIS_URL=redis://localhost:6379 # READ_DATABASE_URL is set, reader (default 50). # BUZZ_DB_POOL_SIZE=50 +# Writer-session Postgres timeouts for buzz-db-backed pools and the relay audit +# pool, all in milliseconds; 0 disables. The separately deployed push gateway +# owns its own database and session policy and does not consume these knobs. +# lock_timeout: fail a statement that waits this long on any lock instead of +# parking behind a wedged holder (default 5000). +# BUZZ_DB_LOCK_TIMEOUT_MS=5000 +# idle_in_transaction_session_timeout: reap sessions idle inside an open +# transaction — bounds how long a wedged client can hold locks (default 60000). +# BUZZ_DB_IDLE_TXN_TIMEOUT_MS=60000 +# statement_timeout: cap any single statement's runtime. Off by default — +# startup migrations/backfills legitimately run long statements. Warning: a +# pathologically low value (e.g. 1) also times out connection setup and can +# prevent any DB connection from establishing. +# BUZZ_DB_STATEMENT_TIMEOUT_MS=0 + # ----------------------------------------------------------------------------- # Typesense (search) # ----------------------------------------------------------------------------- @@ -259,6 +274,10 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # app launch while keeping the current identity and relay data. # VITE_BUZZ_FORCE_FRESH_ONBOARDING=true +# Protected internal builds only: selects the module graph that contains the +# default-off Bestie experiment. Official OSS builds must leave this unset. +# VITE_BUZZ_BESTIE=1 + # ── Subscription & filtering ───────────────────────────────────────────────── # Subscribe mode: "mentions" (default), "all", or "config" (rule-based). # BUZZ_ACP_SUBSCRIBE=mentions @@ -282,6 +301,14 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Set to true to process the agent's own messages (default: ignore self). # BUZZ_ACP_NO_IGNORE_SELF=false +# ── Session scoping ────────────────────────────────────────────────────────── +# How ACP provider sessions are scoped in channels: "channel" (default) or +# "thread". "channel" keeps one provider session per channel (legacy). "thread" +# gives each canonical channel thread its own isolated provider session; direct +# messages stay conversation-scoped either way. Ships as "channel" so thread +# scoping can be canaried and rolled back without code changes. +# BUZZ_ACP_SESSION_POLICY=channel + # ── Context ────────────────────────────────────────────────────────────────── # Max context messages fetched for thread replies and DMs (0–100). 0 = disabled. # BUZZ_ACP_CONTEXT_MESSAGE_LIMIT=12 diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml new file mode 100644 index 00000000000..af5861e5f13 --- /dev/null +++ b/.github/workflows/_ci-clients.yml @@ -0,0 +1,148 @@ +name: CI / Clients +on: + workflow_call: + inputs: + web: + required: true + type: boolean + mobile: + required: true + type: boolean + lane: + required: true + type: string + outputs: + web_result: + value: ${{ jobs.results.outputs.web_result }} + mobile_result: + value: ${{ jobs.results.outputs.mobile_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + web: + name: Web + runs-on: ubuntu-latest + timeout-minutes: 15 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.web) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Web lint and format + run: just web-check + - name: Web build + run: just web-build + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + mobile: + name: Mobile + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.mobile) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Compute Hermit cache key + id: hermit-bin-hash + run: | + hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)" + echo "hash=$hash" >> "$GITHUB_OUTPUT" + - name: Restore Hermit package cache + id: hermit-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.cache/hermit/pkg + key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} + restore-keys: ${{ runner.os }}-hermit-cache- + - name: Prime Flutter SDK + run: flutter --version + - name: Save Hermit package cache + if: always() && steps.hermit-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + continue-on-error: true + with: + path: ~/.cache/hermit/pkg + key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} + - name: Restore pub cache + id: pub-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + restore-keys: pub-${{ runner.os }}- + - name: Install dependencies + run: cd mobile && flutter pub get + - name: Save pub cache + if: always() && steps.pub-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + continue-on-error: true + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + - name: Format check + run: cd mobile && dart format --output=none --set-exit-if-changed . + - name: Analyze + run: cd mobile && flutter analyze + - name: Test + run: cd mobile && flutter test + - name: Build Android debug APK + run: just mobile-build-android + + mobile-swift: + name: Mobile Swift + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.lane == 'mobile-swift' && inputs.mobile + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Install Flutter dependencies + run: cd mobile && flutter pub get + - name: Build + run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit + - name: Test + run: swift test --package-path mobile/ios/BuzzPushKit + - name: Build complete unsigned iOS release + run: cd mobile && flutter build ios --release --no-codesign --no-pub + + results: + name: Results + if: ${{ always() }} + needs: [web, mobile, mobile-swift] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + web_result: ${{ needs.web.result }} + mobile_result: ${{ needs.mobile.result }} + steps: + - run: echo "Captured client job results" diff --git a/.github/workflows/_ci-desktop-macos.yml b/.github/workflows/_ci-desktop-macos.yml new file mode 100644 index 00000000000..728f17f3243 --- /dev/null +++ b/.github/workflows/_ci-desktop-macos.yml @@ -0,0 +1,110 @@ +name: CI / Desktop macOS +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + outputs: + desktop_macos_result: + value: ${{ jobs.results.outputs.desktop_macos_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-build-macos: + name: Desktop Build (macOS) + runs-on: macos-latest + timeout-minutes: 45 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Create sidecar placeholders + run: | + TARGET=$(rustc -vV | sed -n 's|host: ||p') + 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" + # Mesh rev is derived from Cargo.lock so a dependency bump needs no + # lockstep edit here; the cache key tracks it automatically. + - name: Resolve mesh-llm rev + id: mesh_rev + run: | + set -euo pipefail + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') + [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } + echo "rev=$REV" >> "$GITHUB_OUTPUT" + echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" + - name: Restore mesh llama build cache + id: llama_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ github.workspace }}/.cache/mesh-llama + key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Build mesh llama native libraries + if: steps.llama_cache.outputs.cache-hit != 'true' + env: + MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} + run: | + set -euo pipefail + cargo fetch --manifest-path desktop/src-tauri/Cargo.toml + SHORT="$MESH_REV_SHORT" + MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) + if [[ -z "$MESH_ROOT" ]]; then + echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" + exit 1 + fi + export LLAMA_STAGE_BACKEND=metal + export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" + export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 + "$MESH_ROOT/scripts/prepare-llama.sh" pinned + "$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 + - name: Save mesh llama build cache + if: steps.llama_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ github.workspace }}/.cache/mesh-llama + key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Build Tauri app + run: cd desktop && pnpm tauri build + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + LLAMA_STAGE_BACKEND: metal + LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal + SKIPPY_LLAMA_AUTO_BUILD: "0" + + results: + name: Results + if: ${{ always() }} + needs: [desktop-build-macos] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_macos_result: ${{ needs.desktop-build-macos.result }} + steps: + - run: echo "Captured Desktop Build (macOS) result" diff --git a/.github/workflows/_ci-desktop.yml b/.github/workflows/_ci-desktop.yml new file mode 100644 index 00000000000..667b4841418 --- /dev/null +++ b/.github/workflows/_ci-desktop.yml @@ -0,0 +1,244 @@ +name: CI / Desktop +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + outputs: + desktop_result: + value: ${{ jobs.results.outputs.desktop_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-core: + name: Desktop Core + runs-on: ubuntu-latest + timeout-minutes: 45 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install Tauri dependencies (Linux) + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 + sudo apt-get install -y --no-install-recommends \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=120 \ + build-essential \ + curl \ + file \ + libasound2-dev \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + wget + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Desktop lint and format + run: just desktop-check + - name: Desktop unit tests + run: just desktop-test + - name: Desktop build + run: just desktop-build + - name: Desktop Tauri clippy + run: just desktop-tauri-clippy + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri check + run: just desktop-tauri-check + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri tests + run: just desktop-tauri-test + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri compiled-flag verification + run: just desktop-tauri-test-compiled-flags + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Upload desktop e2e artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-artifacts + path: | + desktop/playwright-report + desktop/test-results + if-no-files-found: ignore + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + desktop-smoke-e2e: + name: Desktop Smoke E2E (${{ matrix.shard }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Desktop E2E build + run: pnpm -C desktop build:e2e + - name: Desktop smoke e2e + run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" + working-directory: desktop + - name: Upload desktop smoke e2e artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} + path: | + desktop/playwright-report + desktop/playwright-report.json + desktop/test-results + if-no-files-found: ignore + retention-days: 7 + + desktop: + name: Desktop + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [desktop-core, desktop-smoke-e2e, desktop-windows-build] + if: always() && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + steps: + - name: Check desktop jobs + run: | + if [ "${{ needs.desktop-core.result }}" != "success" ]; then + echo "Desktop Core finished with: ${{ needs.desktop-core.result }}" + exit 1 + fi + if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then + echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" + exit 1 + fi + if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then + echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" + exit 1 + fi + echo "Desktop jobs passed" + + desktop-windows-build: + name: Desktop Windows Build + runs-on: windows-latest + timeout-minutes: 20 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + - name: Install desktop dependencies + shell: bash + run: pnpm install --frozen-lockfile + - name: Build both protected-feature selections + shell: pwsh + run: | + Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:VITE_BUZZ_BESTIE = "1" + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + results: + name: Results + if: ${{ always() }} + needs: [desktop] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_result: ${{ needs.desktop.result }} + steps: + - run: echo "Captured Desktop job results" diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml new file mode 100644 index 00000000000..c0fc3d5333f --- /dev/null +++ b/.github/workflows/_ci-relay.yml @@ -0,0 +1,611 @@ +name: CI / Relay and PostgreSQL +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + lane: + required: true + type: string + outputs: + desktop_e2e_relay_result: + value: ${{ jobs.results.outputs.desktop_e2e_relay_result }} + desktop_e2e_integration_result: + value: ${{ jobs.results.outputs.desktop_e2e_integration_result }} + backend_integration_result: + value: ${{ jobs.results.outputs.backend_integration_result }} + relay_e2e_result: + value: ${{ jobs.results.outputs.relay_e2e_result }} + postgres_tests_result: + value: ${{ jobs.results.outputs.postgres_tests_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-e2e-relay: + name: Desktop E2E Relay + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'artifacts' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} + steps: + - 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 + # everything they embed, including migrations via sqlx migrate!. + - name: Restore relay artifacts cache + id: relay-artifacts-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }} + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + with: + workspaces: | + . + desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; + # the bounded PR 5224 trial writes only to its isolated merge-ref scope. + - name: Set up sccache + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. + with: + version: v0.16.0 + - name: Install cargo-nextest + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Build relay artifacts + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + env: + RUSTC_WRAPPER: sccache + run: | + cargo build --profile ci -p buzz-relay -p git-credential-nostr + cargo nextest archive \ + --cargo-profile ci \ + -p buzz-db \ + -p buzz-relay \ + -p buzz-test-client \ + --lib \ + --test e2e_event_reminder \ + --archive-file target/ci/backend-integration-tests.tar.zst + postgres_package_args=() + while IFS= read -r package; do + postgres_package_args+=(-p "$package") + done < <(scripts/postgres-test-packages.sh) + if [[ "${#postgres_package_args[@]}" -eq 0 ]]; then + echo "no PostgreSQL test packages were discovered" >&2 + exit 1 + fi + cargo nextest archive \ + --cargo-profile ci \ + "${postgres_package_args[@]}" \ + --lib \ + --tests \ + --archive-file target/ci/postgres-tests.tar.zst + - name: Save relay artifacts cache + # PR-scoped exact-source entries cannot warm main or other PRs and churn + # the shared cache pool. sccache provides read-only PR reuse instead. + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }} + - name: Upload relay artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-relay + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + if-no-files-found: error + retention-days: 1 + + postgres-tests: + name: PostgreSQL Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + if: inputs.lane == 'postgres' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U buzz -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PG_BIN_DIR: /usr/bin + REDIS_URL: redis://localhost:6379 + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Tune disposable PostgreSQL + env: + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + # Database-per-test cloning forces checkpoints. Durability is redundant + # for this disposable service and makes runtime depend on runner disk I/O. + psql --dbname postgres --set ON_ERROR_STOP=1 <<'SQL' + ALTER SYSTEM SET fsync = off; + ALTER SYSTEM SET full_page_writes = off; + ALTER SYSTEM SET synchronous_commit = off; + SELECT pg_reload_conf(); + SQL + psql --dbname postgres --tuples-only --no-align --command \ + "SELECT bool_and(setting = 'off') FROM pg_settings WHERE name IN ('fsync', 'full_page_writes', 'synchronous_commit')" \ + | grep --fixed-strings --line-regexp t + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Download backend test archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: PostgreSQL-backed tests + env: + BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + scripts/postgres-test-run.sh \ + --archive-file target/ci/postgres-tests.tar.zst + + desktop-e2e-integration-shard: + name: Desktop E2E Integration (${{ matrix.shard }}/2) + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + strategy: + fail-fast: false + matrix: + shard: [1, 2] + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - 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: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Desktop E2E build + run: pnpm -C desktop build:e2e + - name: Wait for integration services + run: | + wait_healthy() { + local service="$1" + local container="$2" + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + echo "${service} is healthy" + return 0 + fi + sleep 2 + done + docker logs "${container}" || true + return 1 + } + wait_healthy "Postgres" "buzz-postgres" + wait_healthy "Redis" "buzz-redis" + wait_healthy "MinIO" "buzz-minio" + - name: Download relay binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The channel reconciler binds + # the deployment community ONCE at boot (outside its retry loop) and + # exits permanently on an unmapped host, so the 'localhost:3000' + # community MUST exist before the relay starts — the retry loop only + # handles late-seeded channels, not a late-seeded community. The relay + # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the + # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE + # below). lower(host) is the unique index → ON CONFLICT target. psql + # isn't on PATH in hermit → exec into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + # Use the already-running docker postgres for desired-state planning instead of + # downloading an embedded Postgres from Maven Central (transient-fetch flake source). + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_DB: buzz + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay + nohup env \ + DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ + REDIS_URL=redis://localhost:6379 \ + RELAY_URL=ws://localhost:3000 \ + BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_RECONCILE_CHANNELS=true \ + BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ + BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \ + BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \ + BUZZ_GIT_PROBE_WRITERS=8 \ + SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ + ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & + echo $! > /tmp/buzz-relay.pid + for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then + cat /tmp/buzz-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then + exit 0 + fi + sleep 1 + done + cat /tmp/buzz-relay.log + exit 1 + - name: Seed desktop e2e data + run: bash scripts/setup-desktop-test-data.sh + - name: Desktop relay-backed e2e + run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" + working-directory: desktop + - name: Upload desktop integration artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-integration-artifacts-${{ matrix.shard }} + path: | + desktop/playwright-report + desktop/playwright-report.json + desktop/test-results + /tmp/buzz-relay.log + if-no-files-found: ignore + retention-days: 7 + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + desktop-e2e-integration: + name: Desktop E2E Integration + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [desktop-e2e-integration-shard] + if: always() && inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + steps: + - name: Check integration shards + run: | + if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then + echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}" + exit 1 + fi + echo "Desktop E2E Integration shards passed" + + backend-integration: + name: Backend Integration (relay e2e) + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - 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 + with: + tool: cargo-nextest@0.9.136 + - 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: Wait for integration services + run: | + wait_healthy() { + local service="$1" + local container="$2" + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + echo "${service} is healthy" + return 0 + fi + sleep 2 + done + docker logs "${container}" || true + return 1 + } + wait_healthy "Postgres" "buzz-postgres" + wait_healthy "Redis" "buzz-redis" + wait_healthy "MinIO" "buzz-minio" + - name: Download relay artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The reminder scheduler binds + # the deployment community ONCE at boot and exits permanently on an + # unmapped host (no retry, unlike the channel reconciler), so the + # 'localhost:3000' community MUST exist before the relay starts — seeding + # after boot leaves the scheduler dead. The relay migrates at boot via + # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply + # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the + # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec + # into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + # Use the already-running docker postgres for desired-state planning instead of + # downloading an embedded Postgres from Maven Central (transient-fetch flake source). + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_DB: buzz + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" + - name: Workflow message provenance unit tests + # The relay's workflow_sink suite is not selected by the infra-free + # unit job. Its ignored database cases run in the isolated PostgreSQL + # lane; keep the pure provenance cases covered here without duplication. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/workflow_sink/)' + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay + nohup env \ + DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ + REDIS_URL=redis://localhost:6379 \ + RELAY_URL=ws://localhost:3000 \ + BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_RECONCILE_CHANNELS=true \ + BUZZ_GIT_PROBE_WRITERS=8 \ + SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ + ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & + echo $! > /tmp/buzz-relay.pid + for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then + cat /tmp/buzz-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then + exit 0 + fi + sleep 1 + done + cat /tmp/buzz-relay.log + exit 1 + - 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 + # a live relay. The schema-drift / migration-version guarantee is owned + # by the buzz-db migration.rs unit tests, not this suite. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'binary(e2e_event_reminder)' \ + --run-ignored ignored-only + env: + RELAY_URL: ws://localhost:3000 + - name: Upload relay log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: backend-integration-relay-log + path: /tmp/buzz-relay.log + if-no-files-found: ignore + + relay-e2e: + name: Relay E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + save-if: ${{ github.event_name != 'pull_request' }} + # Reuse the relay + git-credential-nostr built by Desktop E2E Relay + # instead of compiling them a second time. + - name: Download relay binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr + ./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 --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: + RELAY_URL: ws://localhost:3000 + GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 + - name: Upload relay logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: relay-e2e-artifacts + path: /tmp/buzz-relay.log + if-no-files-found: ignore + + + results: + name: Results + if: ${{ always() }} + needs: [desktop-e2e-relay, postgres-tests, desktop-e2e-integration, backend-integration, relay-e2e] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_e2e_relay_result: ${{ needs.desktop-e2e-relay.result }} + desktop_e2e_integration_result: ${{ needs.desktop-e2e-integration.result }} + backend_integration_result: ${{ needs.backend-integration.result }} + relay_e2e_result: ${{ needs.relay-e2e.result }} + postgres_tests_result: ${{ needs.postgres-tests.result }} + steps: + - run: echo "Captured Relay and PostgreSQL job results" diff --git a/.github/workflows/_ci-rust.yml b/.github/workflows/_ci-rust.yml new file mode 100644 index 00000000000..f49cc96eb2e --- /dev/null +++ b/.github/workflows/_ci-rust.yml @@ -0,0 +1,219 @@ +name: CI / Rust +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + lane: + required: true + type: string + outputs: + rust_lint_result: + value: ${{ jobs.results.outputs.rust_lint_result }} + unit_tests_result: + value: ${{ jobs.results.outputs.unit_tests_result }} + windows_rust_result: + value: ${{ jobs.results.outputs.windows_rust_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + rust-lint: + name: Rust Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust || inputs.desktop_rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + save-if: ${{ github.event_name != 'pull_request' }} + - name: Format check + run: just fmt-check + - name: Desktop Tauri format check + run: just desktop-tauri-fmt-check + - name: Clippy + run: just clippy + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + key: sherpa-cache-v1 + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Unit tests + run: just test-unit + + server-cross-compile: + name: Server Cross-Compile + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'cross-compile' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + key: cross-${{ matrix.target }} + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install cross + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cross@0.2.5 + - name: Build server binaries + env: + TARGET: ${{ matrix.target }} + # PRs: compile + build-script gate only (no codegen/link). Main: full link gate. + CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }} + run: | + cross "$CARGO_CMD" --release --target "$TARGET" \ + -p buzz-relay \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p git-sign-nostr + + windows-rust: + name: Windows Rust (x86_64-pc-windows-msvc) + runs-on: windows-latest + # Windows runners are slow and this compiles the workspace + Tauri crate + # cold across four steps; budget generously. + timeout-minutes: 45 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust || inputs.desktop_rust) + permissions: + contents: read + env: + TARGET: x86_64-pc-windows-msvc + steps: + - 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 + # repo-root rust-toolchain.toml, which the runner's preinstalled rustup + # honors on demand; the host triple already is x86_64-pc-windows-msvc. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: | + . + desktop/src-tauri + key: windows-msvc + save-if: ${{ github.event_name != 'pull_request' }} + # Tauri validates externalBin at compile time, so the Tauri-crate steps + # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's + # Windows naming (binaries/-.exe); empty files suffice for a + # type-check since nothing executes them. + - name: Create sidecar placeholders + shell: bash + run: | + mkdir -p desktop/src-tauri/binaries + for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" + done + - name: Clippy (workspace) + run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings + - name: Check (workspace) + run: cargo check --workspace --all-targets --target $env:TARGET + - name: Test (buzz-dev-mcp) + # The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests + # only gate if this crate is tested ON Windows. + # Serial: windows_resolver_tests mutate process-global env + # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. + run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 + - name: Test (buzz-agent auth coordinator) + # The auth coordinator single-flights on an OS advisory lock, which is + # LockFileEx on Windows; this integration suite drives real second + # processes on the same lock file, so it only exercises the Windows + # lock runtime if it runs ON Windows. Every other job compiles it but + # never executes it. Tests exercised on Windows: lock serialization + # (two coordinators race for the same key), cooldown sidecar sharing + # across processes, attempt-sidecar adoption (UserInitiated waiter + # adopts a predecessor's denial), and the in-process single-flight for + # same-key coalescing. Tests that are UNIX-ONLY and NOT executed here: + # crash-release (flock drop on SIGKILL, guarded by #[cfg(unix)]) and + # cross-process cache success/race (on-disk token handoff, also + # #[cfg(unix)]). + run: cargo test -p buzz-agent --target $env:TARGET --test databricks_auth_coordinator + # Smoke-test the new host-prereq contract: Git for Windows (which provides + # bash) is available on the runner, a shell command round-trips, and bash + # does NOT resolve from System32 (so WSL's launcher is never picked up). + # windows-latest runners have Git for Windows pre-installed; the unit tests + # above exercise the MCP resolver itself. This step verifies the host env. + - name: Smoke-test host Git Bash prereq (host env check) + shell: bash + run: | + set -euo pipefail + # Git for Windows ships bash.exe under its bin/ directory; confirm it + # resolves from the standard location the runtime resolver probes first. + bash_path=$(command -v bash 2>/dev/null || true) + [[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; } + echo "Resolved bash: $bash_path" + [[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; } + + # Run a basic pipeline through the resolved bash (same invocation the + # agent uses: bash -c '...'). + out=$(bash -c 'echo hello | tr a-z A-Z') + [[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; } + + # Confirm git itself works — agents run git commands frequently. + git --version + repo=$(mktemp -d) + cd "$repo" + git init -q + git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke + 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 --workspace --all-targets --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Test (Tauri crate) + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + + + results: + name: Results + if: ${{ always() }} + needs: [rust-lint, unit-tests, server-cross-compile, windows-rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + rust_lint_result: ${{ needs.rust-lint.result }} + unit_tests_result: ${{ needs.unit-tests.result }} + windows_rust_result: ${{ needs.windows-rust.result }} + steps: + - run: echo "Captured Rust job results" diff --git a/.github/workflows/_ci-security.yml b/.github/workflows/_ci-security.yml new file mode 100644 index 00000000000..29855e8f781 --- /dev/null +++ b/.github/workflows/_ci-security.yml @@ -0,0 +1,42 @@ +name: CI / Security +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + outputs: + security_result: + value: ${{ jobs.results.outputs.security_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + security: + name: Security + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.event_name == 'push' || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Dependency policy + run: cargo-deny check + + + results: + name: Results + if: ${{ always() }} + needs: [security] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + security_result: ${{ needs.security.result }} + steps: + - run: echo "Captured Security job results" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25a59c32432..a663ef957c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,8 +44,17 @@ jobs: - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' + - '.config/nextest.toml' + - 'scripts/postgres-test-*.sh' + - 'scripts/reconcile-schema-after-pgschema.sql' + - 'bin/pgschema' + - 'bin/.pgschema-*.pkg' + - 'scripts/check-postgres-test-discovery.py' + - 'scripts/test-postgres-test-discovery.sh' + - 'scripts/test-postgres-test-wrapper.sh' - 'deny.toml' - '.github/workflows/ci.yml' + - '.github/workflows/_ci-*.yml' - 'scripts/run-tests.sh' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' @@ -73,6 +82,12 @@ jobs: - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' + - '.github/workflows/_ci-*.yml' + - name: Validate PostgreSQL test discovery + if: github.event_name == 'push' || steps.filter.outputs.rust == 'true' + run: | + scripts/test-postgres-test-discovery.sh + scripts/test-postgres-test-wrapper.sh - name: Release workflow source contract run: scripts/test-release-ref-contract.sh - name: Relay image eligibility contract @@ -87,6 +102,8 @@ jobs: run: | scripts/test-mobile-release-contract.sh scripts/test-mobile-release-candidate-publisher.sh + - name: Desktop instance environment contract + run: scripts/test-desktop-instance-detection.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh - name: Codex security review contract @@ -95,1164 +112,298 @@ jobs: run: | scripts/test-rust-cache-contract.sh scripts/test-rust-cache-contract-regressions.sh + - name: CI required-context isolation contract + run: scripts/test-ci-required-context-isolation.sh - name: File size policy run: just file-size-check - rust-lint: - name: Rust Lint + dead-token-guard: + name: Dead Token Reference Guard runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' + timeout-minutes: 5 permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - save-if: ${{ github.event_name != 'pull_request' }} - - name: Format check - run: just fmt-check - - name: Desktop Tauri format check - run: just desktop-tauri-fmt-check - - name: Clippy - run: just clippy + - name: Check for dead API token references in client code + run: | + # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. + # Relay crates are excluded — they still use token auth internally. + PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_' + PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example' + EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool' + if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then + echo "::error::Dead API token references found in client code. See above." + exit 1 + fi + echo "No dead token references found." - unit-tests: - name: Unit Tests - runs-on: ubuntu-latest - timeout-minutes: 30 + rust: + name: Rust + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' + uses: ./.github/workflows/_ci-rust.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: required + + rust-cross-compile-domain: + name: Rust Cross-Compile needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - key: sherpa-cache-v1 - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install cargo-nextest - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Unit tests - run: just test-unit + uses: ./.github/workflows/_ci-rust.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: cross-compile - desktop-core: - name: Desktop Core - runs-on: ubuntu-latest - timeout-minutes: 45 + desktop-domain: + name: Desktop Domain needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read + uses: ./.github/workflows/_ci-desktop.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + + relay-artifacts-domain: + name: Relay Artifact Producer + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: artifacts + + postgres-domain: + name: PostgreSQL Domain + needs: [changes, relay-artifacts-domain] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: postgres + + desktop-macos-domain: + name: Desktop macOS Domain + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-desktop-macos.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + + relay-domain: + name: Relay and PostgreSQL + needs: [changes, relay-artifacts-domain] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: required + + clients: + name: Clients + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.web == 'true' || needs.changes.outputs.mobile == 'true' + uses: ./.github/workflows/_ci-clients.yml + with: + web: ${{ needs.changes.outputs.web == 'true' }} + mobile: ${{ needs.changes.outputs.mobile == 'true' }} + lane: required + + mobile-swift-domain: + name: Mobile Swift Domain + needs: [changes] + if: needs.changes.outputs.mobile == 'true' + uses: ./.github/workflows/_ci-clients.yml + with: + web: ${{ needs.changes.outputs.web == 'true' }} + mobile: ${{ needs.changes.outputs.mobile == 'true' }} + lane: mobile-swift + + security-domain: + name: Security Domain + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-security.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + + rust-lint: + name: Rust Lint + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install Tauri dependencies (Linux) - env: - DEBIAN_FRONTEND: noninteractive - run: | - sudo apt-get update \ - -o Acquire::Retries=3 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 - sudo apt-get install -y --no-install-recommends \ - -o Acquire::Retries=3 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 \ - -o DPkg::Lock::Timeout=120 \ - build-essential \ - curl \ - file \ - libasound2-dev \ - libayatana-appindicator3-dev \ - libgtk-3-dev \ - librsvg2-dev \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - libxdo-dev \ - patchelf \ - wget - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Desktop lint and format - run: just desktop-check - - name: Desktop unit tests - run: just desktop-test - - name: Desktop build - run: just desktop-build - - name: Desktop Tauri clippy - run: just desktop-tauri-clippy - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri check - run: just desktop-tauri-check + - name: Check Rust Lint result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri tests - run: just desktop-tauri-test - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri compiled-flag verification - run: just desktop-tauri-test-compiled-flags + RESULT: ${{ needs.rust.outputs.rust_lint_result }} + run: test "$RESULT" = success + + unit-tests: + name: Unit Tests + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Unit Tests result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Upload desktop e2e artifacts - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-artifacts - path: | - desktop/playwright-report - desktop/test-results - if-no-files-found: ignore - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + RESULT: ${{ needs.rust.outputs.unit_tests_result }} + run: test "$RESULT" = success - desktop-smoke-e2e: - name: Desktop Smoke E2E (${{ matrix.shard }}) + windows-rust: + name: Windows Rust (x86_64-pc-windows-msvc) + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + needs: [changes, rust] runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - permissions: - contents: read + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop E2E build - run: pnpm -C desktop build:e2e - - name: Desktop smoke e2e - run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 - - name: Summarize flaky tests - if: ${{ !cancelled() }} - run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" - working-directory: desktop - - name: Upload desktop smoke e2e artifacts - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} - path: | - desktop/playwright-report - desktop/playwright-report.json - desktop/test-results - if-no-files-found: ignore - retention-days: 7 + - name: Check Windows Rust result + env: + RESULT: ${{ needs.rust.outputs.windows_rust_result }} + run: test "$RESULT" = success desktop: name: Desktop + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, desktop-domain] runs-on: ubuntu-latest timeout-minutes: 5 - needs: [changes, desktop-core, desktop-smoke-e2e] - if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') - permissions: - contents: read + permissions: {} steps: - - name: Check desktop jobs - run: | - if [ "${{ needs.desktop-core.result }}" != "success" ]; then - echo "Desktop Core finished with: ${{ needs.desktop-core.result }}" - exit 1 - fi - if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then - echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" - exit 1 - fi - echo "Desktop jobs passed" + - name: Check Desktop result + env: + RESULT: ${{ needs.desktop-domain.outputs.desktop_result }} + run: test "$RESULT" = success - desktop-e2e-relay: - name: Desktop E2E Relay + desktop-build-macos: + name: Desktop Build (macOS) + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, desktop-macos-domain] runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - env: - SCCACHE_GHA_ENABLED: "true" - SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} + timeout-minutes: 5 + permissions: {} steps: - - 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 - # everything they embed, including migrations via sqlx migrate!. - - name: Restore relay artifacts cache - id: relay-artifacts-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - with: - workspaces: | - . - desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; - # the bounded PR 5224 trial writes only to its isolated merge-ref scope. - - name: Set up sccache - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. - with: - version: v0.16.0 - - name: Install cargo-nextest - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Build relay artifacts - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + - name: Check Desktop Build (macOS) result env: - RUSTC_WRAPPER: sccache - run: | - cargo build --profile ci -p buzz-relay -p git-credential-nostr - cargo nextest archive \ - --cargo-profile ci \ - -p buzz-db \ - -p buzz-relay \ - -p buzz-test-client \ - --lib \ - --test e2e_event_reminder \ - --archive-file target/ci/backend-integration-tests.tar.zst - - name: Save relay artifacts cache - # PR-scoped exact-source entries cannot warm main or other PRs and churn - # the shared cache pool. sccache provides read-only PR reuse instead. - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} - - name: Upload relay artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-relay - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - if-no-files-found: error - retention-days: 1 + RESULT: ${{ needs.desktop-macos-domain.outputs.desktop_macos_result }} + run: test "$RESULT" = success - desktop-e2e-integration-shard: - name: Desktop E2E Integration (${{ matrix.shard }}/2) + desktop-e2e-relay: + name: Desktop E2E Relay + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain] runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - strategy: - fail-fast: false - matrix: - shard: [1, 2] - permissions: - contents: read + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - 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: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop E2E build - run: pnpm -C desktop build:e2e - - name: Wait for integration services - run: | - wait_healthy() { - local service="$1" - local container="$2" - for attempt in $(seq 1 60); do - status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") - if [ "${status}" = "healthy" ]; then - echo "${service} is healthy" - return 0 - fi - sleep 2 - done - docker logs "${container}" || true - return 1 - } - wait_healthy "Postgres" "buzz-postgres" - wait_healthy "Redis" "buzz-redis" - wait_healthy "MinIO" "buzz-minio" - - name: Download relay binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Apply schema and seed deployment community - # MT: the relay resolves each request's tenant from the communities host - # map and fails closed on an unmapped host. The channel reconciler binds - # the deployment community ONCE at boot (outside its retry loop) and - # exits permanently on an unmapped host, so the 'localhost:3000' - # community MUST exist before the relay starts — the retry loop only - # handles late-seeded channels, not a late-seeded community. The relay - # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the - # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE - # below). lower(host) is the unique index → ON CONFLICT target. psql - # isn't on PATH in hermit → exec into the buzz-postgres container. + - name: Check Desktop E2E Relay result env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PGPASSWORD: buzz_dev - PGDATABASE: buzz - # Use the already-running docker postgres for desired-state planning instead of - # downloading an embedded Postgres from Maven Central (transient-fetch flake source). - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_DB: buzz - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - run: | - ./bin/pgschema apply --file schema/schema.sql --auto-approve - docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql - docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -qtA -c " - INSERT INTO communities (id, host) - VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') - ON CONFLICT (lower(host)) DO NOTHING - ;" - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay - nohup env \ - DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ - REDIS_URL=redis://localhost:6379 \ - RELAY_URL=ws://localhost:3000 \ - BUZZ_BIND_ADDR=0.0.0.0:3000 \ - BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ - BUZZ_REQUIRE_AUTH_TOKEN=false \ - BUZZ_RECONCILE_CHANNELS=true \ - BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ - BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \ - BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \ - BUZZ_GIT_PROBE_WRITERS=8 \ - SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ - ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & - echo $! > /tmp/buzz-relay.pid - for attempt in $(seq 1 60); do - if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then - cat /tmp/buzz-relay.log - exit 1 - fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) - if [ "${status_code}" = "200" ]; then - exit 0 - fi - sleep 1 - done - cat /tmp/buzz-relay.log - exit 1 - - name: Seed desktop e2e data - run: bash scripts/setup-desktop-test-data.sh - - name: Desktop relay-backed e2e - run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 - - name: Summarize flaky tests - if: ${{ !cancelled() }} - run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" - working-directory: desktop - - name: Upload desktop integration artifacts - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-integration-artifacts-${{ matrix.shard }} - path: | - desktop/playwright-report - desktop/playwright-report.json - desktop/test-results - /tmp/buzz-relay.log - if-no-files-found: ignore - retention-days: 7 - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + RESULT: ${{ needs.relay-artifacts-domain.outputs.desktop_e2e_relay_result }} + run: test "$RESULT" = success desktop-e2e-integration: name: Desktop E2E Integration + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest timeout-minutes: 5 - needs: [changes, desktop-e2e-integration-shard] - if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') - permissions: - contents: read + permissions: {} steps: - - name: Check integration shards - run: | - if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then - echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}" - exit 1 - fi - echo "Desktop E2E Integration shards passed" + - name: Check Desktop E2E Integration result + env: + RESULT: ${{ needs.relay-domain.outputs.desktop_e2e_integration_result }} + run: test "$RESULT" = success backend-integration: name: Backend Integration (relay e2e) + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read + timeout-minutes: 5 + permissions: {} steps: - - 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 - with: - tool: cargo-nextest@0.9.136 - - 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: Wait for integration services - run: | - wait_healthy() { - local service="$1" - local container="$2" - for attempt in $(seq 1 60); do - status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") - if [ "${status}" = "healthy" ]; then - echo "${service} is healthy" - return 0 - fi - sleep 2 - done - docker logs "${container}" || true - return 1 - } - wait_healthy "Postgres" "buzz-postgres" - wait_healthy "Redis" "buzz-redis" - wait_healthy "MinIO" "buzz-minio" - - name: Download relay artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Apply schema and seed deployment community - # MT: the relay resolves each request's tenant from the communities host - # map and fails closed on an unmapped host. The reminder scheduler binds - # the deployment community ONCE at boot and exits permanently on an - # unmapped host (no retry, unlike the channel reconciler), so the - # 'localhost:3000' community MUST exist before the relay starts — seeding - # after boot leaves the scheduler dead. The relay migrates at boot via - # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply - # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the - # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec - # into the buzz-postgres container. + - name: Check Backend Integration result env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PGPASSWORD: buzz_dev - PGDATABASE: buzz - # Use the already-running docker postgres for desired-state planning instead of - # downloading an embedded Postgres from Maven Central (transient-fetch flake source). - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_DB: buzz - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - run: | - ./bin/pgschema apply --file schema/schema.sql --auto-approve - docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql - docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -qtA -c " - INSERT INTO communities (id, host) - VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') - ON CONFLICT (lower(host)) DO NOTHING - ;" - - name: Replaceable persistence PostgreSQL tests - # Transaction, concurrency, and mention-index coverage for the - # replaceable-event store seam. These tests require real Postgres and - # are ignored by the infrastructure-free unit-test job. - run: | - filter='package(buzz-db) and test(/tests::(parameterized_|concurrent_parameterized_)/)' - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E "${filter}" \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Database pressure observability PostgreSQL tests - # Explicit pool acquisition and advisory-lock metrics require real - # Postgres and are ignored by the infrastructure-free unit-test job. - run: | - filter='package(buzz-db) and test(/observability::tests::(pool_acquire_records_success_timeout_and_error_with_wait_time|advisory_lock_records_success_contention_timeout_and_error)/)' - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E "${filter}" \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay - nohup env \ - DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ - REDIS_URL=redis://localhost:6379 \ - RELAY_URL=ws://localhost:3000 \ - BUZZ_BIND_ADDR=0.0.0.0:3000 \ - BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ - BUZZ_REQUIRE_AUTH_TOKEN=false \ - BUZZ_RECONCILE_CHANNELS=true \ - BUZZ_GIT_PROBE_WRITERS=8 \ - SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ - ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & - echo $! > /tmp/buzz-relay.pid - for attempt in $(seq 1 60); do - if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then - cat /tmp/buzz-relay.log - exit 1 - fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) - if [ "${status_code}" = "200" ]; then - exit 0 - fi - sleep 1 - done - cat /tmp/buzz-relay.log - exit 1 - - name: Invite security tests - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ - --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 - # a live relay. The schema-drift / migration-version guarantee is owned - # by the buzz-db migration.rs unit tests, not this suite. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'binary(e2e_event_reminder)' \ - --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: Admin API nip98 read-write attribution test - # The only real HTTP → nip98 operator principal → mutation → cross-table - # attribution coverage: an authenticated operator's dismiss attributes - # to the operator's own key with relay_operator authority. Staffing - # PUT/DELETE attribution is covered by - # nip98_staffing_put_and_delete_write_attributed_audit_rows in the - # roster-audit lane below. #[ignore]d in the default suite — see - # api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(=api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API unrostered-signer replay invariant - # The only causal proof that a validly-signing but unrostered key cannot - # consume NIP-98 replay slots: it asserts principal resolution fails - # BEFORE the replay ID is claimed (tracking.claim_count() == 0). This - # test is non-ignored, so it runs neither in Backend Integration's - # ignored-only selectors nor in the infra-free unit job — the unit job's - # api::admin selector excludes it because DB-free it only passes by - # waiting out the ~30s sqlx acquire timeout on a read-route fallthrough. - # It lives here so a reachable Postgres resolves (and fails) the lookup - # fast instead of timing out. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API roster-audit / timeout / canonicalization security tests - # Security-review fixes for the roster admin API, all #[ignore]d in the - # default suite (they need Postgres) and selected by no other job: - # - buzz-db relay_operators::tests: audit pre-image trail, per-target - # lock serialization, insertion-time audit ordering, and - # audit-failure rollback coupling. - # - buzz-db relay_operators::tests last-operator invariant: sole DB - # operator cannot self-demote or self-delete to zero, config presence - # lifts the guard, and concurrent cross-target deletes racing to zero - # leave exactly one operator (roster-wide advisory lock). - # - buzz-relay api::admin: NIP-98 staffing writes attributed audit rows, - # adversarial expirationSecs rejected at the resolve route, mixed-case - # staffing normalizes to one canonical row. - # - # --test-threads=1: the last-operator invariant counts the roster - # globally, and the sole-operator tests clear the roster then assert - # their operator is the only one. They must not race each other on the - # shared test roster, so this lane runs serially. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - --test-threads=1 \ - -E '(package(buzz-db) and test(=relay_operators::tests::roster_mutations_write_pre_image_audit_rows)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_upserts_serialize_and_record_true_pre_image)) or (package(buzz-db) and test(=relay_operators::tests::audit_order_follows_seq_under_backward_clock)) or (package(buzz-db) and test(=relay_operators::tests::audit_insert_failure_rolls_back_roster_mutation)) or (package(buzz-db) and test(=relay_operators::tests::demoting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::deleting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::config_present_allows_deleting_last_db_operator)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_deletes_racing_to_zero_leave_one_operator)) or (package(buzz-relay) and test(=api::admin::tests::nip98_staffing_put_and_delete_write_attributed_audit_rows)) or (package(buzz-relay) and test(=api::admin::tests::resolve_route_rejects_adversarial_expiration_and_leaves_report_open)) or (package(buzz-relay) and test(=api::admin::tests::mixed_case_non_config_staffing_normalizes_to_one_row))' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API escalation-scoping tests - # Escalation scoping for the moderation queue, all #[ignore]d (they need - # Postgres) and selected by no other job: - # - GET /reports defaults to the escalated-only backstop, scope=all - # restores full visibility, explicit status= overrides the default. - # - member reports with category 'illegal' auto-escalate at ingestion - # while every other category still lands 'open'. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-relay) and test(=api::admin::tests::reports_default_lists_escalated_only)) or (package(buzz-relay) and test(=api::admin::tests::reports_scope_all_lists_every_status)) or (package(buzz-relay) and test(=api::admin::tests::reports_explicit_status_filter_overrides_default)) or (package(buzz-db) and test(=moderation::tests::illegal_report_auto_escalates_at_ingest)) or (package(buzz-db) and test(=moderation::tests::non_illegal_report_lands_open_at_ingest)) or (package(buzz-db) and test(=relay_admin_actions::tests::auto_escalated_report_reopens_like_an_admin_escalated_one))' \ - --run-ignored ignored-only + RESULT: ${{ needs.relay-domain.outputs.backend_integration_result }} + run: test "$RESULT" = success + + postgres-tests: + name: PostgreSQL Tests + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, postgres-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check PostgreSQL Tests result 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 - with: - name: backend-integration-relay-log - path: /tmp/buzz-relay.log - if-no-files-found: ignore + RESULT: ${{ needs.postgres-domain.outputs.postgres_tests_result }} + run: test "$RESULT" = success relay-e2e: name: Relay E2E + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - save-if: ${{ github.event_name != 'pull_request' }} - # Reuse the relay + git-credential-nostr built by Desktop E2E Relay - # instead of compiling them a second time. - - name: Download relay binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr - ./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 --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 + - name: Check Relay E2E result env: - RELAY_URL: ws://localhost:3000 - GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - - name: Media read-auth e2e - # Reads require kind:24242 `t=get` auth, so these binaries are the only - # coverage that a real relay rejects bare reads and honours host- and - # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so - # the lane never ran; select it here, where MinIO and the seeded - # 'localhost:3000' community already exist. - # --no-fail-fast: without it cargo stops after the first failing binary, - # so one broken case hides every later binary's result. - run: | - cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture - env: - RELAY_URL: ws://localhost:3000 - RELAY_HTTP_URL: http://localhost:3000 - - name: Upload relay logs - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: relay-e2e-artifacts - path: /tmp/buzz-relay.log - if-no-files-found: ignore + RESULT: ${{ needs.relay-domain.outputs.relay_e2e_result }} + run: test "$RESULT" = success web: name: Web + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.web == 'true') + needs: [changes, clients] runs-on: ubuntu-latest - timeout-minutes: 15 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.web == 'true' - permissions: - contents: read + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Web lint and format - run: just web-check - - name: Web build - run: just web-build - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + - name: Check Web result + env: + RESULT: ${{ needs.clients.outputs.web_result }} + run: test "$RESULT" = success mobile: name: Mobile + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.mobile == 'true') + needs: [changes, clients] runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.mobile == 'true' - permissions: - contents: read + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Compute Hermit cache key - id: hermit-bin-hash - run: | - hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)" - echo "hash=$hash" >> "$GITHUB_OUTPUT" - - name: Restore Hermit package cache - id: hermit-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ~/.cache/hermit/pkg - key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} - restore-keys: ${{ runner.os }}-hermit-cache- - - name: Prime Flutter SDK - run: flutter --version - - name: Save Hermit package cache - if: always() && steps.hermit-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - continue-on-error: true - with: - path: ~/.cache/hermit/pkg - key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} - - name: Restore pub cache - id: pub-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ~/.pub-cache - key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} - restore-keys: pub-${{ runner.os }}- - - name: Install dependencies - run: cd mobile && flutter pub get - - name: Save pub cache - if: always() && steps.pub-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - continue-on-error: true - with: - path: ~/.pub-cache - key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} - - name: Format check - run: cd mobile && dart format --output=none --set-exit-if-changed . - - name: Analyze - run: cd mobile && flutter analyze - - name: Test - run: cd mobile && flutter test - - name: Build Android debug APK - run: just mobile-build-android + - name: Check Mobile result + env: + RESULT: ${{ needs.clients.outputs.mobile_result }} + run: test "$RESULT" = success - mobile-swift: - name: Mobile Swift - runs-on: macos-latest - timeout-minutes: 10 - needs: [changes] - if: needs.changes.outputs.mobile == 'true' - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Build - run: swift build --package-path mobile/ios/BuzzPushKit - - name: Build release - run: swift build -c release --package-path mobile/ios/BuzzPushKit - - name: Test - run: swift test --package-path mobile/ios/BuzzPushKit security: name: Security - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Dependency policy - run: cargo-deny check - - dead-token-guard: - name: Dead Token Reference Guard + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, security-domain] runs-on: ubuntu-latest timeout-minutes: 5 - permissions: - contents: read + permissions: {} steps: - - 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. - # Relay crates are excluded — they still use token auth internally. - PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_' - PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example' - EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool' - if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then - echo "::error::Dead API token references found in client code. See above." - exit 1 - fi - echo "No dead token references found." - - server-cross-compile: - name: Server Cross-Compile - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - strategy: - fail-fast: false - matrix: - target: - - x86_64-unknown-linux-musl - - aarch64-unknown-linux-musl - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - key: cross-${{ matrix.target }} - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install cross - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cross@0.2.5 - - name: Build server binaries - env: - TARGET: ${{ matrix.target }} - # PRs: compile + build-script gate only (no codegen/link). Main: full link gate. - CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }} - run: | - cross "$CARGO_CMD" --release --target "$TARGET" \ - -p buzz-relay \ - -p buzz-acp \ - -p buzz-agent \ - -p buzz-dev-mcp \ - -p git-credential-nostr \ - -p git-sign-nostr - - windows-rust: - name: Windows Rust (x86_64-pc-windows-msvc) - runs-on: windows-latest - # Windows runners are slow and this compiles the workspace + Tauri crate - # cold across four steps; budget generously. - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' - permissions: - contents: read - env: - TARGET: x86_64-pc-windows-msvc - steps: - - 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 - # repo-root rust-toolchain.toml, which the runner's preinstalled rustup - # honors on demand; the host triple already is x86_64-pc-windows-msvc. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: | - . - desktop/src-tauri - key: windows-msvc - save-if: ${{ github.event_name != 'pull_request' }} - # Tauri validates externalBin at compile time, so the Tauri-crate steps - # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's - # Windows naming (binaries/-.exe); empty files suffice for a - # type-check since nothing executes them. - - name: Create sidecar placeholders - shell: bash - run: | - mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do - touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" - done - - name: Clippy (workspace) - run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings - - name: Check (workspace) - run: cargo check --workspace --all-targets --target $env:TARGET - - name: Test (buzz-dev-mcp) - # The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests - # only gate if this crate is tested ON Windows. - # Serial: windows_resolver_tests mutate process-global env - # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. - run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 - # Smoke-test the new host-prereq contract: Git for Windows (which provides - # bash) is available on the runner, a shell command round-trips, and bash - # does NOT resolve from System32 (so WSL's launcher is never picked up). - # windows-latest runners have Git for Windows pre-installed; the unit tests - # above exercise the MCP resolver itself. This step verifies the host env. - - name: Smoke-test host Git Bash prereq (host env check) - shell: bash - run: | - set -euo pipefail - # Git for Windows ships bash.exe under its bin/ directory; confirm it - # resolves from the standard location the runtime resolver probes first. - bash_path=$(command -v bash 2>/dev/null || true) - [[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; } - echo "Resolved bash: $bash_path" - [[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; } - - # Run a basic pipeline through the resolved bash (same invocation the - # agent uses: bash -c '...'). - out=$(bash -c 'echo hello | tr a-z A-Z') - [[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; } - - # Confirm git itself works — agents run git commands frequently. - git --version - repo=$(mktemp -d) - cd "$repo" - git init -q - git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke - 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 --workspace --all-targets --target $env:TARGET - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Test (Tauri crate) - run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - desktop-build-macos: - name: Desktop Build (macOS) - runs-on: macos-latest - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Create sidecar placeholders - run: | - TARGET=$(rustc -vV | sed -n 's|host: ||p') - 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" - # Mesh rev is derived from Cargo.lock so a dependency bump needs no - # lockstep edit here; the cache key tracks it automatically. - - name: Resolve mesh-llm rev - id: mesh_rev - run: | - set -euo pipefail - REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') - [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } - echo "rev=$REV" >> "$GITHUB_OUTPUT" - echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" - - name: Restore mesh llama build cache - id: llama_cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ github.workspace }}/.cache/mesh-llama - key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - - name: Build mesh llama native libraries - if: steps.llama_cache.outputs.cache-hit != 'true' - env: - MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} - run: | - set -euo pipefail - cargo fetch --manifest-path desktop/src-tauri/Cargo.toml - SHORT="$MESH_REV_SHORT" - MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) - if [[ -z "$MESH_ROOT" ]]; then - echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" - exit 1 - fi - export LLAMA_STAGE_BACKEND=metal - export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" - export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 - "$MESH_ROOT/scripts/prepare-llama.sh" pinned - "$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 - - name: Save mesh llama build cache - if: steps.llama_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ github.workspace }}/.cache/mesh-llama - key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - - name: Build Tauri app - run: cd desktop && pnpm tauri build + - name: Check Security result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - MACOSX_DEPLOYMENT_TARGET: "10.15" - CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" - LLAMA_STAGE_BACKEND: metal - LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal - SKIPPY_LLAMA_AUTO_BUILD: "0" + RESULT: ${{ needs.security-domain.outputs.security_result }} + run: test "$RESULT" = success diff --git a/.github/workflows/codex-security-review.yml b/.github/workflows/codex-security-review.yml index df558a877d7..766dc430b90 100644 --- a/.github/workflows/codex-security-review.yml +++ b/.github/workflows/codex-security-review.yml @@ -214,7 +214,7 @@ jobs: if: needs.prepare-review.outputs.authorized == 'true' runs-on: ubuntu-latest environment: codex-review - timeout-minutes: 30 + timeout-minutes: 40 concurrency: group: codex-security-review-${{ needs.prepare-review.outputs.pr_number }} cancel-in-progress: true @@ -222,13 +222,13 @@ jobs: contents: read env: CODEX_MODEL: gpt-5.6-sol - CODEX_REASONING_EFFORT: max + CODEX_REASONING_EFFORT: high CODEX_REVIEW_API_KEY_PRESENT: ${{ secrets.CODEX_REVIEW_API_KEY != '' }} REVIEW_CONTEXT: review-context REVIEW_REPOSITORY: review-target REVIEW_DIFF_FILE: .git/codex-review.diff outputs: - review_json: ${{ steps.run_codex.outputs.final-message }} + review_json: ${{ steps.salvage.outputs.review_json }} steps: - name: Checkout exact pull request head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -287,6 +287,12 @@ jobs: # action's local proxy rather than entering the Codex subprocess. - name: Review pull request id: run_codex + # Codex CLI ≥0.149.x can leave a PTY descendant holding inherited stdio + # after the turn completes, stalling the action indefinitely. The output + # file is written before the hang, so a timeout here wastes at most 30 + # minutes instead of the full 40, and the salvage step recovers the result. + timeout-minutes: 30 + continue-on-error: true uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 env: # Checkout and fetch are complete. Remove runner credentials from the @@ -306,6 +312,8 @@ jobs: safety-strategy: drop-sudo permission-profile: ':read-only' working-directory: ${{ github.workspace }}/${{ env.REVIEW_CONTEXT }} + # Written before the hang; salvaged below if the step times out. + output-file: ${{ runner.temp }}/codex-review.json output-schema: | { "type": "object", @@ -442,6 +450,51 @@ jobs: assumptions. Review only the authorized PR range and ground every finding in a changed hunk and a plausible failure or abuse path. + # Salvage the finished review whether the Codex step completed cleanly or + # timed out due to the PTY-shutdown hang. Prefer the action's final-message + # output (set on a clean exit); fall back to the output file written by the + # CLI before the hang. Fail the job only when neither source is available or + # the recovered JSON is not a valid review shape. + - name: Salvage review output + id: salvage + if: always() + env: + FINAL_MESSAGE: ${{ steps.run_codex.outputs.final-message }} + CODEX_OUTPUT_FILE: ${{ runner.temp }}/codex-review.json + run: | + json="" + + # Prefer the action output set on a clean exit. + if [ -n "$FINAL_MESSAGE" ]; then + json="$FINAL_MESSAGE" + echo "source=action-output" >> "$GITHUB_STEP_SUMMARY" + elif [ -s "$CODEX_OUTPUT_FILE" ]; then + json="$(cat "$CODEX_OUTPUT_FILE")" + echo "source=output-file" >> "$GITHUB_STEP_SUMMARY" + else + echo "No review output from action or output file." >&2 + exit 1 + fi + + # Minimal shape validation: non-empty JSON object with overall_risk. + if ! echo "$json" | python3 -c " + import sys, json + d = json.load(sys.stdin) + assert isinstance(d, dict), 'not an object' + assert 'overall_risk' in d, 'missing overall_risk' + "; then + echo "Review JSON failed shape validation." >&2 + exit 1 + fi + + # Write as a multiline output (GitHub-safe delimiter). + EOF=$(dd if=/dev/urandom bs=15 count=1 2>/dev/null | base64) + { + echo "review_json<<${EOF}" + echo "$json" + echo "${EOF}" + } >> "$GITHUB_OUTPUT" + post-review: name: Post Codex Security Review needs: [prepare-review, security-review] diff --git a/AGENTS.md b/AGENTS.md index 24115501ad8..9d1a6afd429 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,91 @@ Additional rules: --- +## Review-Proven Rules + +These rules distill the recurring findings from the last 25 PRs' review +threads — 53% of substantive review findings were repeats of the clusters +below, and reviewed PRs averaged ~5 review rounds. A second, independent +mining pass over 71 agent-review rooms (303 findings, Aug 18–29) confirmed +the same clusters and measured how often authors actually fix each class +once flagged: test-seam binding and unbounded-resource findings were fixed +**100%** of the time, swallowed-error findings **90%**, stale-state races +**70%** — these are not style opinions, they are defects authors agree +with on sight. Apply the rules **before writing code**; each cites the +PRs where reviewers litigated it. + +1. **Every caught failure must leave a durable retry record or propagate.** + Never catch-log-and-return-success (opt-out revocation permanently + abandoned, PR #6269), never convert a terminal failure into an + authoritative success/empty result (cold-history `error` → `success` + with `[]`, PR #7013), and never delete the durable journal an operation + depends on before its retry has actually succeeded (PR #6269). If a + partial failure can orphan committed state (installations, endpoints), + schedule its cleanup/renewal durably (PRs #6269, #6996, #7013). + +2. **Fence async results by generation; clear derived metadata on every + removal path.** A completing in-flight probe or fetch must verify it is + still the newest before writing its result (stale login-shell probe + recached a false-negative PATH, PR #6904). Provenance/ownership metadata + attached to synthetic state must be updated or cleared on *all* paths + that remove or refresh that state — typed deletion, toolbar removal, + profile/name refresh; enumerate the paths and test each (PR #6956 burned + 4 rounds on this one class). Backfill and live subscriptions must + overlap — a gap between a finite history REQ and the live subscription + silently drops events (PR #3995); a retired chunk must not keep a stale + scope fence (PR #6996). (PRs #3995, #6904, #6956, #6996) + +3. **Regression tests must bind the production seam and be falsifiable.** + See "Review-Proven Test Standards" in [TESTING.md](TESTING.md) for the + full rule — in short: a guard whose removal doesn't fail any test + protects nothing; bind regression tests to the production code path, + not test-only helpers. (PRs #6807, #6980, #6996, #7013) + +4. **Bound every resource, loop, and process tree.** Cap captured + output (unbounded discovery temp files exhausted disk and overran the + deadline, PR #6904). Containment failures are errors, not warnings — a + tolerated Job Object creation failure or a `setsid` escape leaks whole + process trees (PR #6904). Retry/re-subscribe loops need backoff and a + terminal state: a persistent failure must not self-amplify into an + unbounded refresh loop (PR #6996), and check zero-delay edge cases + (`remainingMs()==0` selected the wrong fallback window, PR #6996). + (PRs #6904, #6996) + +5. **One user action = one atomic persist.** Implementing a single user + commit as N independent durable writes leaves torn state on partial + failure (theme "Set" as three independent notifier persists, PR #6944; + relay-commit vs. local-save recovery gap, PR #6269). Persist one + snapshot, or order the writes so every prefix is consistent and the + remainder is durably retried per rule 1. (PRs #6269, #6944) + +6. **A guard that hides the only recovery affordance is a functional + failure.** Before adding a visibility predicate or state fence, ask: + if the state it assumes goes wrong, does the user still have a way + back? A fence that permanently suppresses "jump to latest" after a + bounded correction fails strands the user silently — two reviewers + flagged this independently (PR #6807). + +7. **Audit assistive semantics on every new visual component.** The + agent-review lanes flagged accessibility defects on 44 findings across + the Aug 18–29 window — the second-largest cluster — and authors fixed + the concrete ones (duplicate VoiceOver stops on native controls, + actionable labels owned by two widgets at once, PR #6680; missing or + decorative-leaking semantics on new UI, PRs #6611, #6702, #6885, #6905, + #6908). New UI ships with: one owner per actionable label, no duplicate + screen-reader stops, and explicit semantics for every interactive + element. (PRs #6611, #6680, #6702, #6885, #6905, #6908, #6980) + +8. **Every input modality is a first-class seam.** Keyboard, pointer, and + hotkey paths must not silently diverge: `Shift+Space` treated as plain + `Space` because the guard omitted `shiftKey` (PR #6862), keyboard + ownership not released on blur, modifier keys dropped on the non-mouse + path (PRs #5958, #6793, #6860, #6908, #7006). When adding an input + handler, enumerate the modalities that can reach it and test the + non-primary ones — that's where the defects were. (PRs #5958, #5972, + #6793, #6860, #6862, #6908, #7006) + +--- + ## Key Patterns **Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary. @@ -589,11 +674,13 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. over raw `Theme.of(context)` calls. - **Keep widgets small and composable.** One public widget per file; push private sub-widgets (`_Foo`) into sibling `part` files under a - `/` folder rather than growing the page file. Hard ceiling: - **1000 lines/file**, enforced across Desktop, Web, and Mobile by the + `/` folder rather than growing the page file. Mobile's hard ceiling is + **1200 lines/file**, enforced with the other surface-specific limits by the repository-level `just file-size-check` gate (`just check`, CI, and every - pre-push). If the guard trips, **split the file — never bump the limit or add - an override to slip under it.** + pre-push). If an individual file trips the guard, **split the file — never + bump a surface limit or add an override merely to admit that file.** + Deliberate repository-wide policy revisions must update the enforced rules, + tests, and guidance together. - Feature modules must not import from other feature modules — only from `shared/`. - Use `Grid` tokens for spacing, `Radii` for border radius. @@ -646,3 +733,10 @@ usage. - [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships - [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds - [README.md](README.md) — project overview and quick start + +### Mention editor contract + +Autocomplete inserts a literal full label and a separator, including multi-word +names. Only autocomplete settlement may move the caret past that separator; +internal label spaces and deliberate ArrowLeft/click movement must be respected. +See `docs/mention-editor.md` and `desktop/tests/e2e/mention-spacing.spec.ts`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 892082d96c6..9905e97b767 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -325,6 +325,15 @@ This prevents a race where a non-member receives live fan-out events from a priv After registering, the REQ handler queries Postgres for stored events matching the filters (up to 500 per filter, hard cap). These are sent as `["EVENT", sub_id, event]` frames before `["EOSE", sub_id]`. New events arriving after EOSE are delivered via the fan-out path. +**Client consumption invariant.** A client rebuilding channel state must +open its live subscription before (or overlapping) the finite history +REQ — a gap between the last backfill page and live delivery silently +drops events and rebuilds stale state (PR #3995). When the relay sends a +terminal CLOSED, the subscription is removed server-side; any client-side +ownership tied to it (chunk/scope fences) must be released in the same +step, or live delivery stops permanently while the client believes it is +subscribed (PR #6996). + --- ## 6. Crate Reference @@ -352,7 +361,7 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored) |----------|---------| | `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. | | `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. | -| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. | +| `is_not_global_unicast(ip)` | SSRF protection: enumerated-deny policy — blocks a specific set of non-public address classes and accepts everything else (including addresses not covered by an explicit deny rule, e.g. `fe00::1`). Blocked IPv4 classes: loopback, private (RFC 1918), link-local, CGNAT (RFC 6598), benchmarking (RFC 2544), IETF Protocol Assignments (192.0.0.0/24, exceptions: 192.0.0.9 PCP anycast, 192.0.0.10 TURN anycast), documentation (RFC 5737: 192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526), multicast (RFC 5771, 224/4), reserved/class-E (240/4). Blocked IPv6 classes: loopback, unspecified, ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 anycast, 2001:3::/32 AMT, 2001:4:112::/48 AS112-v6, 2001:20::/28 ORCHIDv2, 2001:30::/28 DETs), documentation (2001:db8::/32, 3fff::/20), 6to4 (2002::/16), Discard-Only (100::/64), Dummy prefix (100:0:0:1::/64), SRv6 SIDs (5f00::/16), NAT64 local-use (64:ff9b:1::/48). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms checked recursively. Compat alias: `is_private_ip`. | **Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime. @@ -737,12 +746,10 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic ### SSRF Protection -`is_private_ip()` in `buzz-core` covers: -- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255) -- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32) -- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address +`is_not_global_unicast(ip)` (compat alias `is_private_ip`) in `buzz-core` is an enumerated-deny policy: it blocks a specific set of non-public address classes and accepts everything else, including addresses not covered by an explicit deny rule (e.g. `fe00::1`). Blocked IPv4 classes: loopback (127.0.0.0/8), private RFC 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8), broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544 (198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526, global=None/blank → conservative deny), multicast/RFC 5771 (224/4), and reserved class-E (240/4). Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast, 2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535, 2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374), documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4 (2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6 Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252), and NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known (64:ff9b::/96, RFC 6052) forms is checked recursively against the IPv4 table; SIIT IPv4-translated (::ffff:0:0:0/96) follows the same path. -Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility). +Applied in: `buzz-auth` (JWKS boundary), `buzz-workflow` (CallWebhook action), +desktop `link_preview` (SSRF check). ### Audit Integrity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0247b570a23..dbe4ba5dd2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,6 +215,64 @@ connections, NIP-42 auth, event ingestion, search indexing, and workflow execution. `just test` starts Docker services automatically if they're not already running. +### PostgreSQL-backed tests + +PostgreSQL-backed tests run in a dedicated nextest lane. Mark them ignored with +a PostgreSQL reason and place them in a module whose name ends in +`postgres_tests`. Standalone integration-test targets use a `postgres_` +filename prefix instead. Tests that also require infrastructure beyond +PostgreSQL and Redis live under an `external_infra*_tests` module and are +excluded without changing their descriptive function names. + +See the [buzz-db testing guide](crates/buzz-db/TESTING.md) for the crate-level +checklist. + +`scripts/test-postgres-test-discovery.sh` enforces the convention across every +Rust source file. It fails CI when an ignored PostgreSQL test would be omitted, +or when a Redis-only or hybrid test is accidentally included, so module or file +renames cannot silently change lane membership. The archive and runner derive +their Cargo package set from the same markers, so a database test in a new crate +does not require a separate package-list update. + +The `postgres-ci` nextest profile creates one database per test process, so +destructive and concurrent tests must use the database URL supplied through +`BUZZ_TEST_DATABASE_URL`, `TEST_DATABASE_URL`, or `DATABASE_URL`; do not +hard-code the shared development database. Ordinary tests receive the committed +desired-state schema from `schema/schema.sql`. Tests under +`migration::postgres_tests` receive an empty database and own the embedded +migration lifecycle. A test outside that module whose behavior intentionally +depends on migration-created triggers or seed rows uses a +`migration_schema_` function-name prefix and also receives an empty database +with `BUZZ_TEST_SCHEMA_MODE=migration`. Test helpers that normally call the +migrator honor `BUZZ_TEST_SCHEMA_MODE=desired` so the desired-state contract is +not re-migrated. + +Tests that inspect cluster-wide PostgreSQL state or open least-privilege +sessions use a `cluster_global_` function-name segment; migration-backed cases +use `migration_schema_cluster_global_`. Nextest serializes this small group +while the database-isolated remainder stays parallel. + +The setup process requires a PostgreSQL role that can create and drop databases +and owns the databases it creates; the harness itself does not require +superuser access. The complete inventory includes privilege-boundary tests that +create temporary roles and inspect all sessions, so grant that role +`CREATEROLE` and membership in `pg_read_all_stats` (or use an ephemeral +superuser, as CI does). +Set `BUZZ_POSTGRES_ADMIN_URL` to that role's maintenance database, and set +`PGHOST`, `PGPORT`, `PGUSER`, and `PGPASSWORD` for the desired-state +schema bootstrap. PostgreSQL client tools are resolved from `PATH` unless +`PG_BIN_DIR` is set. Tests that use Redis read `REDIS_URL`. + +With native PostgreSQL and Redis running, the complete lane is below. The +runner bounds compilation to the packages discovered from the current source +tree and removes the run-scoped desired-state source database on exit. +Per-test and source-database cleanup retries transient PostgreSQL disconnect +races and emits a warning if all five attempts fail. + +```bash +./scripts/postgres-test-run.sh +``` + ### End-to-End Tests End-to-end tests live in `crates/buzz-test-client/tests/`: diff --git a/Cargo.lock b/Cargo.lock index 9544a63b899..9ad2a913e6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,7 @@ dependencies = [ "axum", "base64 0.22.1", "dirs", + "fs2", "getrandom 0.4.3", "hex", "nix 0.31.3", @@ -939,10 +940,12 @@ dependencies = [ "base64 0.22.1", "buzz-core", "chrono", + "futures-util", "hex", "jsonwebtoken", "nostr 0.44.7", "rand 0.10.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", @@ -2969,6 +2972,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index d6ee839f1b0..0af365f52fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } # HTTP client (webhook delivery) -reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false } # Cryptography sha2 = "0.11" diff --git a/Justfile b/Justfile index 32d83355e1c..c81adb2381b 100644 --- a/Justfile +++ b/Justfile @@ -99,6 +99,7 @@ check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clip security-review-check: node --check .github/scripts/codex-security-review.js node --test .github/scripts/codex-security-review.test.js + actionlint .github/workflows/codex-security-review.yml # Run the repository-wide differential file-size ratchet and its policy tests. # The ratchet inspects only files changed from the merge base, so this stays @@ -255,7 +256,18 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs 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." + echo "=== Maximum accepted demo name reaches Rust build validation ===" + DEMO_CONFIG="$(node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..31})" /dev/null 1234567812345678)" + DEMO_SLUG="$(node -e 'console.log(JSON.parse(process.argv[1]).slug)' "$DEMO_CONFIG")" + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" \ + BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG" \ + cargo test compiled_demo_slug_matches_expected -- --ignored --nocapture + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" cargo test --workspace + if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then + echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2 + exit 1 + fi + echo "Both compiled states and the accepted/rejected demo-name boundary verified." # Build the full desktop Tauri app locally (unsigned, for testing) # Sidecar binary list must stay in sync with _ensure-sidecar-stubs above. @@ -276,6 +288,38 @@ desktop-release-build target="aarch64-apple-darwin": pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} +# Build an unsigned named macOS demo DMG with isolated app and runtime identities. +desktop-demo-build demo_name target="aarch64-apple-darwin": + #!/usr/bin/env bash + set -euo pipefail + TARGET={{target}} + [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]] || { echo "Demo DMGs require a macOS Apple target" >&2; exit 2; } + CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX")" + trap 'rm -f "$CONFIG_PATH"' EXIT + DEMO_BUILD_ID="$(node -e 'console.log(require("node:crypto").randomBytes(8).toString("hex"))')" + DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs {{quote(demo_name)}} "$CONFIG_PATH" "$DEMO_BUILD_ID")" + read_config() { node -e 'console.log(JSON.parse(process.argv[1])[process.argv[2]])' "$DEMO_CONFIG" "$1"; } + PRODUCT_NAME="$(read_config productName)" + DMG_VOLUME_NAME="$(read_config dmgVolumeName)" + DMG_FILE_STEM="$(read_config dmgFileStem)" + DEMO_SLUG="$(read_config slug)" + 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" + pnpm install + cd {{desktop_dir}} + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app --config "$CONFIG_PATH" + cd .. + VERSION="$(node -p "require('./desktop/package.json').version")" + DMG_ARCH="${TARGET%%-*}"; [[ "$DMG_ARCH" == "x86_64" ]] && DMG_ARCH=x64 + APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/$PRODUCT_NAME.app" + PLIST="$APP_PATH/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $PRODUCT_NAME" "$PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleName $PRODUCT_NAME" "$PLIST" + codesign --force --deep --sign - "$APP_PATH" + VOL_NAME="$DMG_VOLUME_NAME" ./desktop/scripts/package-macos-dmg.sh "$APP_PATH" "desktop/src-tauri/target/$TARGET/release/bundle/dmg/${DMG_FILE_STEM}_${VERSION}_${DMG_ARCH}.dmg" + # Run desktop checks suitable for CI / pre-push desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test @@ -347,14 +391,21 @@ test-unit: # 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 - # buzz-agent model-capabilities corpus: the Rust half of the - # cross-language drift guard. `model_capabilities.rs` embeds - # scripts/model-capabilities.json + scripts/normative-corpus.json via - # include_str! and replays the full locked corpus as pure in-process tests (no - # infra). Enumerated explicitly because nothing in CI runs - # `cargo test --workspace`; without this step a manifest edit that - # diverges Rust from the corpus ships green. - cargo nextest run -p buzz-agent --lib + # buzz-agent: two infra-free concerns run together by executing the + # whole crate (lib + integration tests), because nothing in CI runs + # `cargo test --workspace`, so without this stanza neither the crate's + # library tests nor its integration tests execute remotely. + # * model-capabilities corpus (lib): the Rust half of the + # cross-language drift guard. `model_capabilities.rs` embeds + # scripts/model-capabilities.json + scripts/normative-corpus.json via + # include_str! and replays the full locked corpus as pure in-process + # tests; without it a manifest edit that diverges Rust from the + # corpus ships green. + # * OAuth auth coordinator (lib concurrency matrix + databricks + # integration tests): lock single-flight, cooldown, cross-process + # crash recovery — infra-free via a stub OIDC provider and an + # injected browser opener, no network or Postgres. + cargo nextest run -p buzz-agent # Admin API auth-boundary tests (api::admin in buzz-relay): the NIP-98 # duplicate-tag rejections, the Host/Origin replay-ordering causal pair, # the admin.localhost origin/advertisement/canonical-URL pins, and the @@ -380,6 +431,10 @@ test-unit: # disabled_mode_still_requires_the_correct_host / _a_matching_origin. cargo nextest run -p buzz-relay --lib \ -E 'test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' + # ACP author-gate and queue tests protect the trust boundary between + # relay events and agent prompts. They are infra-free; ignored lifecycle + # tests remain excluded and run in their dedicated integration lanes. + cargo nextest run -p buzz-acp --lib else ./scripts/run-tests.sh unit fi diff --git a/TESTING.md b/TESTING.md index 0e64b740665..d939265c414 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,6 +16,21 @@ just test # unit + integration (starts Docker if needed) cargo test -p buzz-test-client -- --ignored ``` +### Review-Proven Test Standards + +Mined from the last 25 PRs' review threads (see Review-Proven Rules in +[AGENTS.md](AGENTS.md)); this is the test-quality rule reviewers litigated +most: + +**Regression tests must bind the production seam and be falsifiable.** +A guard whose removal doesn't fail any test protects nothing — mutations +survived the full mobile suite twice (PRs #6996, #7013). Don't bind a +regression test to a test-only helper instead of the production code +path (PR #7013). Give pure predicates a table test over the full input +combination space (PR #6807). Scope Playwright locators — unscoped +`getByText` in a required smoke test is a strict-mode flake (PR #6980). +(PRs #6807, #6980, #6996, #7013) + --- ## Live Local Relay @@ -343,7 +358,7 @@ CLI-side, only two matter for testing: | Symptom | Cause | Fix | |---------|-------|-----| | `relay error 500` or `400: restricted: not a channel member` after a code change | Stale binary | Rebuild and re-export `PATH`; or `cargo run` directly | -| `Address already in use` on relay start (os error 48 on macOS, 98 on Linux) | Another relay (or stale process) holding `:3000` / `:8080` / `:9102` (or your override ports) | The panic line names the failing port — read it first. Then `lsof -iTCP:3000,8080,9102 -sTCP:LISTEN` (or your override equivalents). Kill the offender (`pkill -f buzz-relay`) or use the port-override block in step 3. If you already overrode and *still* collide, a prior reviewer left a relay running on the same alt ports — kill it or pick fresh ports | +| `Address already in use` on relay start (os error 48 on macOS, 98 on Linux) | Another relay (or stale process) holding `:3000` / `:8080` / `:9102` (or your override ports) | Metrics-listener failures emit a `metrics_bind` lifecycle terminal with reason `bind`. Check the configured ports with `lsof -iTCP:3000,8080,9102 -sTCP:LISTEN` (or your override equivalents). Kill the offender (`pkill -f buzz-relay`) or use the port-override block in step 3. If you already overrode and *still* collide, a prior reviewer left a relay running on the same alt ports — kill it or pick fresh ports | | `auth_error: BUZZ_PRIVATE_KEY is required` | Env not exported into the CLI's shell | `export BUZZ_PRIVATE_KEY=...` (or pass `--private-key`) | | `auth_error: BUZZ_AUTH_TAG verification failed … signature verification failed` | A stale `BUZZ_AUTH_TAG` inherited from a parent shell. The local dev relay rejects it. | `unset BUZZ_AUTH_TAG` (see the scrub block in step 1) | | `auth-required: verification failed` on a closed relay | NIP-OA attestation needed | Set `BUZZ_AUTH_TAG` to the owner-issued JSON, or relax `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | diff --git a/benchmarks/buzz-dataset/README.md b/benchmarks/buzz-dataset/README.md index cfce3b257a2..6df3fbadcf3 100644 --- a/benchmarks/buzz-dataset/README.md +++ b/benchmarks/buzz-dataset/README.md @@ -16,6 +16,7 @@ willing to read. | [`interleaved-agent-reports`](interleaved-agent-reports) | Workflow | Retains and synthesizes every report in a batch of agent messages | | [`cross-thread-requests`](cross-thread-requests) | Workflow | Keeps simultaneous top-level requests isolated and replies to both exact threads | | [`ambiguous-user-mention`](ambiguous-user-mention) | Workflow | Resolves duplicate display names and notifies only the intended pubkey | +| [`memory-retrieval`](memory-retrieval) | Regression | Answers from harness-seeded cold memory without the value appearing in channel history | For `reply-to-thread` and `user-mention` the graded behavior is **deliberately absent from `instruction.md`** — it has to come from `buzz-acp`'s production diff --git a/benchmarks/buzz-dataset/memory-retrieval/README.md b/benchmarks/buzz-dataset/memory-retrieval/README.md new file mode 100644 index 00000000000..ef6e63950f2 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/README.md @@ -0,0 +1,18 @@ +# memory-retrieval + +Before the agent starts, the harness runs `buzz mem set` with the agent's own +Buzz credentials to seed five similar cold memories. One records the exact +total customer count for April 2024; the other four contain customer counts for +nearby months or related April metrics. The harness then delivers +`instruction.md`, which contains only the retrieval question and does not reveal +the answer or memory slug. No channel message contains the answer, so +conversation history cannot supply it. + +Full credit requires the exact customer count `352,345` in the threaded answer. +Equivalent comma-free formatting is accepted, but rounded or approximate counts +receive no credit. Credit is also voided if the answer mentions another number, +apart from the requested year `2024`. This includes every count drawn from the +distractor memories, so dumping several memories or selecting the wrong one does +not pass — the answer must resolve to the correct value alone. The verifier does +not inspect tool calls: seeding is deterministic harness setup, and retrieval is +graded only through the observable answer. diff --git a/benchmarks/buzz-dataset/memory-retrieval/environment/Dockerfile b/benchmarks/buzz-dataset/memory-retrieval/environment/Dockerfile new file mode 100644 index 00000000000..29f16f3c412 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/memory-retrieval/instruction.md b/benchmarks/buzz-dataset/memory-retrieval/instruction.md new file mode 100644 index 00000000000..0a7a96173d5 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/instruction.md @@ -0,0 +1 @@ +How many total customers did we have in April 2024? diff --git a/benchmarks/buzz-dataset/memory-retrieval/task.toml b/benchmarks/buzz-dataset/memory-retrieval/task.toml new file mode 100644 index 00000000000..a018303ce58 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/task.toml @@ -0,0 +1,25 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/memory-retrieval" +description = "Answer a question using a harness-seeded cold-memory rule." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "agents", "memory", "retrieval"] + +[metadata] +evaluation_layer = "regression" +difficulty = "hard" +category = "collaboration" +tags = ["agents", "memory", "retrieval"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/memory-retrieval/tests/test.sh b/benchmarks/buzz-dataset/memory-retrieval/tests/test.sh new file mode 100755 index 00000000000..79434035e3c --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/memory-retrieval/tests/verify.py b/benchmarks/buzz-dataset/memory-retrieval/tests/verify.py new file mode 100755 index 00000000000..e3ffe9f5c6a --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/tests/verify.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for pre-seeded cold-memory retrieval.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +EXPECTED_CUSTOMERS = 352_345 +ALLOWED_CONTEXT_NUMBERS = frozenset({2024}) +# Numbers that appear only in the distractor memories. Mentioning any of them +# means the answer pulled from the wrong memory (or dumped several), so it does +# not demonstrate that the correct value was selected. +DISTRACTOR_NUMBERS = frozenset( + { + 361_250, # total-customers-per-month: monthly average + 351_340, # customer-value-metric: last month's customers + 2_400, # customer-value-metric: revenue per customer + 325_401, # customers-metrics-spring-24: March total + 3_710, # customers-metrics-spring-24: April active customers named John + 21_604, # new-customers-april-2024: April new customers + } +) +NUMBER = re.compile(r"(? dict[str, float]: + return { + "reward": 0.0, + "answer_correct": 0.0, + "threaded_reply": 0.0, + "evidence_complete": 0.0, + } + + +def _numbers(content: str) -> list[float]: + values: list[float] = [] + for token in NUMBER.findall(content): + try: + values.append(float(token.replace(",", ""))) + except ValueError: + continue + return values + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + + identities = evidence.get("identities", {}) + agents = ( + [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + if isinstance(identities, dict) + else [] + ) + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + question_id = evidence.get("task_event_id") + trial = evidence.get("trial", {}) + question_channel = trial.get("channel_id") if isinstance(trial, dict) else None + + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + replies = [ + row + for row in messages + if agent_pubkey + and row.get("pubkey") == agent_pubkey + and row.get("channel_id") == question_channel + and row.get("reply_to_event_id") == question_id + ] + answer = replies[-1] if replies else None + content = str(answer.get("content", "")) if answer else "" + values = _numbers(content) + mentions_expected = any(value == EXPECTED_CUSTOMERS for value in values) + mentions_distractor = any(value in DISTRACTOR_NUMBERS for value in values) + noise_numbers = [ + value + for value in values + if value != EXPECTED_CUSTOMERS and value not in ALLOWED_CONTEXT_NUMBERS + ] + answer_correct = float(mentions_expected and not noise_numbers) + threaded_reply = float(answer is not None) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "memory-retrieval" + and evidence.get("truncated") is False + and len(agents) == 1 + and isinstance(question_id, str) + and isinstance(question_channel, str) + ) + + structural_score = float(threaded_reply == 1.0 and evidence_complete == 1.0) + metrics = { + "reward": answer_correct * structural_score, + "answer_correct": answer_correct, + "threaded_reply": threaded_reply, + "evidence_complete": evidence_complete, + } + return metrics, { + "question_event_id": question_id, + "question_channel_id": question_channel, + "answer_message_id": answer.get("id") if answer else None, + "answer_content": content, + "parsed_numbers": values, + "expected_customers": EXPECTED_CUSTOMERS, + "mentions_expected": mentions_expected, + "mentions_distractor": mentions_distractor, + "noise_numbers": noise_numbers, + "allowed_context_numbers": sorted(ALLOWED_CONTEXT_NUMBERS), + "distractor_numbers": sorted(DISTRACTOR_NUMBERS), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/README.md b/benchmarks/harbor-buzz-orchestra/README.md index df859b41ab5..a0bd08a6ae0 100644 --- a/benchmarks/harbor-buzz-orchestra/README.md +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -69,8 +69,8 @@ directory of this harness, not a subdirectory of it — scores Buzz product behavior alongside task correctness. It covers direct thread replies, callback user mentions, targeted reads of named paths, exact channel membership, multiline delivery, non-waking narrative names, batched reports, cross-thread -isolation, and ambiguous identities. Run one task with the production base -prompt from the checked-out source build: +isolation, ambiguous identities, and explicit cold-memory retrieval. Run one +task with the production base prompt from the checked-out source build: ```bash just benchmark \ diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py index 797e3a860c2..a29b86e7314 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -167,6 +167,8 @@ async def run( "--name", credential.agent_id, ) + await self._seed_memories(orchestrator, trial) + for credential in trial.credentials: agents.append( await self._launch_agent( environment=environment, @@ -786,6 +788,39 @@ async def _verify_m1_output( f"and its stripped text must equal 'Hello, world!' ({detail})" ) + async def _seed_memories( + self, credential: AgentCredential, trial: TrialHandle + ) -> None: + """Seed task-declared cold memory without exposing its value to the agent.""" + for seed in fixture_for(trial.task_name).memory_seeds: + try: + process = await asyncio.create_subprocess_exec( + self.buzz_cli_binary, + "mem", + "set", + seed.slug, + "-", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={ + **os.environ, + "BUZZ_RELAY_URL": self._user_relay_url(trial), + "BUZZ_PRIVATE_KEY": credential.nostr_secret_key, + "BUZZ_AUTH_TAG": credential.nostr_auth_tag, + }, + ) + _, stderr = await process.communicate(seed.value.encode()) + except OSError as error: + raise RuntimeLaunchError( + f"cannot seed cold memory {seed.slug!r}: {error}" + ) from None + if process.returncode != 0: + detail = stderr.decode(errors="replace").strip() + raise RuntimeLaunchError( + f"buzz mem set {seed.slug} - exited {process.returncode}: {detail}" + ) + async def _send( self, credential: AgentCredential, diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py index 451b97f9c12..87cffbbcf2c 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py @@ -32,6 +32,14 @@ class ScriptedMessage: mention_orchestrator: bool = True +@dataclass(frozen=True, slots=True) +class MemorySeed: + """A cold-memory value seeded under the orchestrator's identity.""" + + slug: str + value: str + + @dataclass(frozen=True, slots=True) class BuzzTaskFixture: """Relay state a task needs before the agent receives its prompt.""" @@ -40,6 +48,7 @@ class BuzzTaskFixture: scripted_messages: tuple[ScriptedMessage, ...] = () observe_channel_names: tuple[str, ...] = () user_display_name: str | None = None + memory_seeds: tuple[MemorySeed, ...] = () # Whether the task's verifier grades the exported relay snapshot. Only # these tasks fail when the export fails; a Terminal-Bench task is graded # by its own tests and must not be errored by a snapshot hiccup. @@ -59,6 +68,7 @@ class BuzzTaskFixture: INTERLEAVED_AGENT_REPORTS_TASK = "interleaved-agent-reports" CROSS_THREAD_REQUESTS_TASK = "cross-thread-requests" AMBIGUOUS_USER_MENTION_TASK = "ambiguous-user-mention" +MEMORY_RETRIEVAL_TASK = "memory-retrieval" _CREATE_CHANNEL_FIXTURE = BuzzTaskFixture( directory=tuple( @@ -161,6 +171,38 @@ class BuzzTaskFixture: requires_evidence=True, ) + +# Noisy memories test retrieval of one relevant value through `buzz mem ls/get`. +_MEMORY_RETRIEVAL_FIXTURE = BuzzTaskFixture( + user_display_name="Amelia Rose Bennett", + memory_seeds=( + MemorySeed( + slug="total-customers-per-month", + value="We average 361,250 customers per month.", + ), + MemorySeed( + slug="customer-value-metric", + value="Last month we had 351,340 customers with a $2400 revenue per customer", + ), + MemorySeed( + slug="customers-metrics-spring-24", + value=( + "In March, we had 325,401 total customers. In April, we had " + "3,710 active customers named John." + ), + ), + MemorySeed( + slug="new-customers-april-2024", + value="There are 21,604 new customers in April 2024.", + ), + MemorySeed( + slug="total-customers-metric", + value="In April 2024, we had 352,345 total customers.", + ), + ), + requires_evidence=True, +) + _FIXTURES = { CREATE_CHANNEL_TASK: _CREATE_CHANNEL_FIXTURE, USER_MENTION_TASK: _USER_MENTION_FIXTURE, @@ -173,6 +215,7 @@ class BuzzTaskFixture: INTERLEAVED_AGENT_REPORTS_TASK: _INTERLEAVED_AGENT_REPORTS_FIXTURE, CROSS_THREAD_REQUESTS_TASK: _CROSS_THREAD_REQUESTS_FIXTURE, AMBIGUOUS_USER_MENTION_TASK: _AMBIGUOUS_USER_MENTION_FIXTURE, + MEMORY_RETRIEVAL_TASK: _MEMORY_RETRIEVAL_FIXTURE, } diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md index f8d2a560bf2..a4a460d7087 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md @@ -9,6 +9,13 @@ endpoint string remains the join key. Every key in these files must be a manifest endpoint name; the loader treats all entries as endpoint configs (no comment keys). +## openai-live-wire-debug.json + +Diagnostic variant of `openai-live.json` for local runs. It enables +`acp::wire=debug`, so retained agent stdout logs include full ACP messages, +including tool-call arguments and results. These logs may contain prompt or +command content; keep them local. The verifier and reward do not read them. + ## m1-local.json M1 wiring proof: both placeholder endpoints resolve to one local llama-server diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live-wire-debug.json b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live-wire-debug.json new file mode 100644 index 00000000000..0403481648d --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live-wire-debug.json @@ -0,0 +1,9 @@ +{ + "gpt-5.6-luna": { + "provider": "openai", + "api_key_env": "OPENAI_COMPAT_API_KEY", + "env": { + "RUST_LOG": "acp::wire=debug" + } + } +} diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py index 0644df63b36..de91726e426 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py @@ -77,6 +77,7 @@ def test_buzz_task_metadata_defines_the_expected_layers(): "user-mention", "read-named-path-outside-workspace", "multiline-message", + "memory-retrieval", "narrative-agent-names", }, "workflow": { @@ -167,7 +168,7 @@ def test_explicit_attempts_override_keeps_one_mixed_buzz_job(): (run,) = benchmark.plan_benchmark_runs(args) assert run.attempts == 7 - assert len(run.include_task) == 9 + assert len(run.include_task) == 10 layered = benchmark.parse_args( [ diff --git a/benchmarks/harbor-buzz-orchestra/testbed/uv.lock b/benchmarks/harbor-buzz-orchestra/testbed/uv.lock index 814f4d3527e..543499b3159 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/uv.lock +++ b/benchmarks/harbor-buzz-orchestra/testbed/uv.lock @@ -717,7 +717,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, ] provides-extras = ["dev"] @@ -743,7 +743,7 @@ requires-dist = [ { name = "harbor-buzz-orchestra", editable = "../" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, ] provides-extras = ["dev"] @@ -2026,27 +2026,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } -sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } -wheels = [ - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca" }, +version = "0.16.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a" }, ] [[package]] diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index 182db9893f6..ecdc9e4cdec 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -1,5 +1,6 @@ """The container runtime must launch the production stack, unmodified.""" +import asyncio import hashlib import json import re @@ -343,6 +344,56 @@ async def test_launch_wires_the_desktop_environment(tmp_path, configured, expect ) +def test_memory_task_disables_auto_memory_injection(tmp_path): + manifest = write_manifest(tmp_path) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = replace(trial_handle((orch,)), task_name="memory-retrieval") + + env = runtime(tmp_path)._agent_env( + trial=trial, + credential=orch, + agent_class=manifest.roster[0], + endpoint=EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"), + remote_prompt="/prompt.md", + ) + + assert env["BUZZ_ACP_CHANNELS"] == "channel" + assert env["BUZZ_ACP_NO_MEMORY"] == "true" + + +@pytest.mark.asyncio +async def test_memory_seed_uses_agent_credentials_and_stdin(tmp_path, monkeypatch): + orch = credential("orch-1", "orchestrator", "orch-model") + trial = replace(trial_handle((orch,)), task_name="memory-retrieval") + captured = [] + + class Process: + def __init__(self, invocation): + self.invocation = invocation + + returncode = 0 + + async def communicate(self, value): + self.invocation["value"] = value + return b"", b"wrote memory" + + async def create_subprocess_exec(*args, **kwargs): + invocation = {"args": args, "env": kwargs["env"]} + captured.append(invocation) + return Process(invocation) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", create_subprocess_exec) + + await runtime(tmp_path)._seed_memories(orch, trial) + + seeds = fixture_for("memory-retrieval").memory_seeds + assert len(captured) == len(seeds) + for invocation, seed in zip(captured, seeds, strict=True): + assert invocation["args"][1:] == ("mem", "set", seed.slug, "-") + assert invocation["env"]["BUZZ_PRIVATE_KEY"] == orch.nostr_secret_key + assert invocation["value"] == seed.value.encode() + + def test_runtime_validates_construction_bounds(tmp_path): # 0 is legal and means unbounded (BUZZ_AGENT_MAX_ROUNDS=0); the trial # budget is the clock. Only negatives are rejected. diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py b/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py index 225cb1d1fa1..f39da50a3b7 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py @@ -6,6 +6,8 @@ from pathlib import Path from types import ModuleType +from harbor_buzz_orchestra.task_fixtures import fixture_for + DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" AGENT = "a" * 64 USER = "u" * 64 @@ -236,3 +238,102 @@ def test_ambiguous_user_mention_targets_only_profile_match(): metrics, _ = verifier.score_evidence(evidence) assert metrics["other_not_notified"] == 0.0 assert metrics["reward"] == 0.0 + + +def test_memory_retrieval_requires_correct_threaded_answer(): + verifier = _verifier("memory-retrieval") + evidence = _base("memory-retrieval", "Amelia Rose Bennett") + question_id = "memory-question" + evidence["task_event_id"] = question_id + answer = _message( + "answer", + "We had 352,345 total customers in April 2024.", + reply_to=question_id, + mentions=[USER], + ) + evidence["messages"] = [answer] + + for correct_answer in ( + "352,345", + "We had 352,345 total customers in April 2024.", + "April 2024 total customers: 352345", + ): + evidence["messages"][0]["content"] = correct_answer + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + for answer_without_exact_total in ( + "361,250", + "351,340", + "$2,400 revenue per customer", + "325,401", + "3,710", + "21,604", + "352,344", + "352,346", + "352,000", + "About 352 thousand", + "Approximately 352.3 thousand", + ): + evidence["messages"][0]["content"] = answer_without_exact_total + metrics, _ = verifier.score_evidence(evidence) + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + for distractor in verifier.DISTRACTOR_NUMBERS: + evidence["messages"][0]["content"] = ( + f"We had 352,345 total customers. Another relevant count was {distractor:,}." + ) + metrics, details = verifier.score_evidence(evidence) + assert details["mentions_expected"] is True + assert details["mentions_distractor"] is True + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + for noise_count in (352_000, 999_999): + evidence["messages"][0]["content"] = ( + f"We had 352,345 total customers, approximately {noise_count:,}." + ) + metrics, details = verifier.score_evidence(evidence) + assert details["mentions_expected"] is True + assert details["mentions_distractor"] is False + assert details["noise_numbers"] == [float(noise_count)] + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + evidence["messages"][0]["content"] = "352,345" + evidence["messages"][0]["reply_to_event_id"] = "wrong-question" + metrics, _ = verifier.score_evidence(evidence) + assert metrics["answer_correct"] == 0.0 + assert metrics["threaded_reply"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_memory_retrieval_answer_exists_only_in_harness_seed(): + verifier = _verifier("memory-retrieval") + fixture = fixture_for("memory-retrieval") + instruction = (DATASET_ROOT / "memory-retrieval" / "instruction.md").read_text( + encoding="utf-8" + ) + + seeds = {seed.slug: seed.value for seed in fixture.memory_seeds} + assert set(seeds) == { + "total-customers-per-month", + "customer-value-metric", + "customers-metrics-spring-24", + "new-customers-april-2024", + "total-customers-metric", + } + assert "352,345" in seeds["total-customers-metric"] + assert sum("352,345" in value for value in seeds.values()) == 1 + seeded_distractors = frozenset( + number + for slug, value in seeds.items() + if slug != "total-customers-metric" + for number in verifier._numbers(value) + if number != 2024 + ) + assert verifier.EXPECTED_CUSTOMERS == 352_345 + assert verifier.DISTRACTOR_NUMBERS == seeded_distractors + assert "352,345" not in instruction + assert "352345" not in instruction.replace(",", "") diff --git a/benchmarks/harbor-buzz-orchestra/uv.lock b/benchmarks/harbor-buzz-orchestra/uv.lock index 05072d81a80..67b6390f365 100644 --- a/benchmarks/harbor-buzz-orchestra/uv.lock +++ b/benchmarks/harbor-buzz-orchestra/uv.lock @@ -696,7 +696,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, ] provides-extras = ["dev"] @@ -1934,27 +1934,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } -sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } -wheels = [ - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca" }, +version = "0.16.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a" }, ] [[package]] diff --git a/bin/.actionlint-1.7.12.pkg b/bin/.actionlint-1.7.12.pkg new file mode 120000 index 00000000000..383f4511d44 --- /dev/null +++ b/bin/.actionlint-1.7.12.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/actionlint b/bin/actionlint new file mode 120000 index 00000000000..432f25e505e --- /dev/null +++ b/bin/actionlint @@ -0,0 +1 @@ +.actionlint-1.7.12.pkg \ No newline at end of file diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..41d9a214bdd 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -147,17 +147,31 @@ Controls which authors' events the harness forwards to the agent. Events from di | `anyone` | Forward all events (no author filtering). | | `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. | +Relay-signed workflow messages delegate to their recorded owner only when they +explicitly target this agent with authenticated workflow-mention provenance. +The owner tag means that owner scheduled the workflow; it does not claim that +the owner authored every word after template rendering. ACP verifies the +provenance against the relay's NIP-11 `self` key, then evaluates the owner under +the same author policy as ordinary messages. Legacy workflow messages and +workflow output without an explicit agent mention remain attributed to the relay +signer. `nobody` remains absolute. + The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode: | Command | Effect | |---------|--------| | `!shutdown` | Gracefully exits the harness. | -| `!cancel` | Cancels the current in-flight turn for that channel, if any. | -| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. | +| `!cancel` | Cancels the current in-flight turn for the command's resolved session scope, if any. | +| `!rotate` | Rotates the ACP session for the command's resolved session scope. If a turn is in flight, it is cancelled and that scoped session is invalidated when the task returns; otherwise the cached scoped session is invalidated immediately. The next queued/received event in that scope starts a fresh session. | + +Under the default `channel` policy, a session scope is the whole channel, so these commands retain their channel-wide behavior. Under the `thread` policy, post the command as a reply in the target thread so `!cancel` or `!rotate` affects only that thread. DMs remain one conversation scope. `!cancel` is a no-op when its scope is idle. -Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle. +Owner control commands must be kind:9 stream messages from the owner, must have body exactly `!cancel`, `!rotate`, or `!shutdown` after trimming, and must mention this agent with a separate `p` tag. They are consumed by the harness instead of being forwarded to the agent. An inline `@Name` changes the body and does not match. With the Buzz CLI, target a thread while preserving the exact command body by passing the mention separately: -Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent. +```bash +buzz messages send --channel --reply-to \ + --mention --content '!cancel' +``` > **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. Set `--respond-to anyone` to disable the gate entirely. @@ -269,7 +283,7 @@ Buzz Desktop supports registering any ACP-speaking agent tool as a selectable ru **Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden. -**Tier-2 — preset catalog** (Cursor, Oh My Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead. +**Tier-2 — preset catalog** (Cursor, Oh My Pi, Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery/presets.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead. > **Note — OpenClaw:** `openclaw acp` is a Gateway-backed bridge; PATH availability shows "Available" even when the OpenClaw Gateway daemon is not running. This is expected tier-2 semantics (same class as a preset with unconfigured auth). The Gateway URL is configured via `OPENCLAW_GATEWAY_URL` (or the equivalent env var from OpenClaw's docs) — set it in the agent's **env vars** in Edit Agent, not in the definition env (the preset definition carries no env entries). Note that `openclaw acp` executes tools inside the Gateway daemon, not the Desktop process, so Desktop-injected `BUZZ_*` env vars do NOT reach the execution locus unless you also set them on the Gateway's own environment. @@ -313,10 +327,9 @@ Invalid files (bad JSON, unknown id, empty command) are skipped with a warning a To add a new runtime to the tier-2 gallery: 1. **Verify the ACP entrypoint** from the vendor's own documentation — do not rely on a PR description alone. Test with the actual binary. -2. **Add a `HarnessDefinition` entry** to the `PRESET_HARNESSES` slice in `desktop/src-tauri/src/managed_agents/discovery.rs`. Fill `id`, `label`, `command`, `args`, `install_instructions_url`, `install_hint`. Leave `env` empty unless the harness requires a specific env var to enable ACP mode. -3. **Add the preset id to `BUILTIN_IDS`** in `desktop/src-tauri/src/managed_agents/custom_harnesses.rs` so custom JSON files cannot shadow it. -4. **Add a bundled logo** (64×64 PNG or optimised SVG) to `desktop/public/harness-logos/.png` and add a corresponding entry to `PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`. Record the source and license in `desktop/public/harness-logos/CREDITS.md`. Only bundle a mark whose upstream license permits redistribution; skipping this step is caught by `presetLogos.test.mjs`, which asserts every `PRESET_HARNESSES` id has a mapped logo that exists on disk. -5. Run `cargo test --lib` and `just desktop-typecheck` to verify everything compiles. +2. **Add a `PresetHarness` entry** to the `PRESET_HARNESSES` slice in `desktop/src-tauri/src/managed_agents/discovery/presets.rs`. Fill `id`, `label`, `command`, `args`, `install_instructions_url`, `install_hint`, and `underlying_cli` when the command wraps a separately installed CLI. Preset ids are automatically reserved so custom JSON files cannot shadow them. +3. **Add a bundled logo** (64×64 PNG or optimised SVG) to `desktop/public/harness-logos/.png` and add a corresponding entry to `PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`. Record the source and license in `desktop/public/harness-logos/CREDITS.md`. Only bundle a mark whose upstream license permits redistribution; skipping this step is caught by `presetLogos.test.mjs`, which asserts every `PRESET_HARNESSES` id has a mapped logo that exists on disk. +4. Run `cargo test --lib` and `just desktop-typecheck` to verify everything compiles. The built-in `BUILTIN_IDS` set (`goose`, `claude`, `codex`, `buzz-agent`, and all current preset ids) is the reserved namespace; every other id is available for custom harnesses. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 4dc4720ed85..7e90b07b6a9 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -1,11 +1,5 @@ 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. @@ -27,6 +21,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | +| `buzz mem` | `set`, `get`, `ls`, `patch`, `rm` | Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. @@ -55,7 +50,7 @@ Open an owner-reviewed draft with `buzz agents draft-create --channel `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. @@ -118,10 +113,11 @@ Do not discover, fetch, load, read, or use relay-backed skills unless the author Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions. - **Keep `core` small.** A line earns a permanent slot only if it matters across most sessions or prevents a sharp repeat mistake. Treat the 65,535-byte hard limit as a wall to stay far from, not a budget to fill — aim to keep `core` under ~10 KB (roughly your healthy baseline). -- **Turn mistakes into durable lessons.** When a mistake exposes a repeatable mechanism, record the invariant in the same session. Keep only the load-bearing rule in `core`; put detailed evidence and procedures in cold memory. If the lesson improves a shared workflow, update the team's shared guidance so others do not have to re-earn it. -- **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/` slug you read on demand — not appended to `core`. -- **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later. +- **Turn mistakes into durable lessons.** When a mistake exposes a repeatable mechanism, record the invariant in the same session. Keep only the load-bearing rule in `core`; put detailed evidence and procedures in cold memory with `buzz mem set`. If the lesson improves a shared workflow, update the team's shared guidance so others do not have to re-earn it. +- **Durable detail goes to a cold `buzz mem set `, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in cold memory you read on demand with `buzz mem get `—not appended to `core`. +- **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `buzz mem` slug if you need it later. Always ask the owner before doing this. - **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it. +- **Cold memory search and hygiene.** Find cold memory with `buzz mem ls` and `buzz mem get`. If a user's prompt contradicts a memory, always ask the owner if they would remove it with `buzz mem rm` or update it with `buzz mem patch`. Never remove or patch a memory without owner approval. - Cite sources with paths, links, or command outputs. No unsupported claims. ## Engineering Discipline diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 2d7b2128320..5b7e27131ec 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -20,11 +20,11 @@ use crate::filter::SubscriptionRule; /// /// Sized for slow turns where the agent may go silent on its outer ACP channel /// while running long sub-tools (e.g. a buzz-agent running another agent, or -/// codex/claude doing multi-minute single tool calls). 900s gives 300s of -/// breathing room above the 600s max shell timeout, so legitimate long-running +/// codex/claude doing multi-minute single tool calls). 1500s gives 300s of +/// breathing room above the 1200s max shell timeout, so legitimate long-running /// tool calls don't race the idle deadline. /// Override via `--idle-timeout` / `BUZZ_ACP_IDLE_TIMEOUT`. -pub(crate) const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900; +pub(crate) const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 1_500; /// Default absolute wall-clock cap per agent turn (2 hours). /// Override via `--max-turn-duration` / `BUZZ_ACP_MAX_TURN_DURATION`. @@ -350,6 +350,19 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)] pub dedup: DedupMode, + /// How ACP provider sessions are scoped in channels. + /// channel (default): one provider session per channel (legacy behavior). + /// thread: each canonical channel thread gets an isolated provider session; + /// direct messages stay conversation-scoped either way. Ships as `channel` + /// so thread scoping can be canaried and rolled back without code changes. + #[arg( + long, + env = "BUZZ_ACP_SESSION_POLICY", + default_value = "channel", + value_enum + )] + pub session_policy: crate::scope::SessionPolicy, + /// How to handle new @mentions while a turn is already in-flight. /// steer (default): cancel+re-prompt, framing the new mention as a message /// that arrived mid-task — the agent keeps working and weaves it in. @@ -503,6 +516,15 @@ pub struct CliArgs { /// Requires `--lazy-pool`; ignored otherwise. 0 disables idle re-sleep. #[arg(long, env = "BUZZ_ACP_IDLE_POOL_SLEEP", default_value_t = 0)] pub idle_pool_sleep: u64, + + /// Unix-seconds replay floor for the startup watermark. A publish-first + /// mention send publishes the triggering message and then spawns this + /// harness, passing the send timestamp here so the first REQ replays past + /// that message however long the spawn takes. Floors older than 15 minutes + /// are clamped to 15 minutes before startup; floors in the future are + /// ignored (the watermark stays at startup time). + #[arg(long, env = "BUZZ_ACP_REPLAY_FLOOR")] + pub replay_floor: Option, } /// Merged NIP-01 subscription filter for a single channel. @@ -536,6 +558,8 @@ pub struct Config { pub initial_message: Option, pub subscribe_mode: SubscribeMode, pub dedup_mode: DedupMode, + /// How ACP provider sessions are scoped in channels (channel vs thread). + pub session_policy: crate::scope::SessionPolicy, pub multiple_event_handling: MultipleEventHandling, pub ignore_self: bool, pub kinds_override: Option>, @@ -590,6 +614,12 @@ pub struct Config { /// woken lazy pool is torn back down to the empty-slot state. 0 = disabled. /// Only meaningful when `lazy_pool` is true. pub idle_pool_sleep_secs: u64, + /// Optional unix-seconds replay floor for the startup watermark + /// (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`), set by a publish-first + /// mention send so the first REQ replays past the already-published + /// triggering message. Clamped where consumed — see + /// `startup_watermark_with_floor`. + pub replay_floor_unix: Option, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, @@ -646,6 +676,35 @@ const SESSION_TITLE_SEPARATOR: &str = " · "; /// survives. Returns the bare agent name when there is no channel, the channel /// name is blank, or no room is left for it. pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> String { + compose_session_title_with_limit(agent, channel_name, SESSION_TITLE_MAX_CHARS) +} + +/// Append the canonical thread root's first eight characters to a session title. +/// Reserve suffix space before truncating names so thread identity always survives. +/// Conversation and heartbeat sessions preserve their existing title behavior. +pub(crate) fn compose_scoped_session_title( + agent: &str, + channel_name: Option<&str>, + thread_root: Option<&str>, +) -> String { + let Some(root) = thread_root.filter(|root| !root.is_empty()) else { + return compose_session_title(agent, channel_name); + }; + let short_root: String = root.chars().take(8).collect(); + let suffix = format!("{SESSION_TITLE_SEPARATOR}{short_root}"); + let budget = SESSION_TITLE_MAX_CHARS.saturating_sub(suffix.chars().count()); + let agent: String = agent.chars().take(budget).collect(); + format!( + "{}{suffix}", + compose_session_title_with_limit(agent.trim_end(), channel_name, budget) + ) +} + +fn compose_session_title_with_limit( + agent: &str, + channel_name: Option<&str>, + max_chars: usize, +) -> String { let Some(channel) = channel_name.and_then(sanitize_session_title) else { return agent.to_string(); }; @@ -653,7 +712,7 @@ pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> let reserved = agent.chars().count() + SESSION_TITLE_SEPARATOR.chars().count() + 1; let channel: String = channel .chars() - .take(SESSION_TITLE_MAX_CHARS.saturating_sub(reserved)) + .take(max_chars.saturating_sub(reserved)) .collect::() .trim_end() .to_string(); @@ -1113,6 +1172,7 @@ impl Config { initial_message: args.initial_message, subscribe_mode: args.subscribe, dedup_mode: args.dedup, + session_policy: args.session_policy, multiple_event_handling: args.multiple_event_handling, ignore_self: !args.no_ignore_self, kinds_override: args.kinds, @@ -1140,6 +1200,7 @@ impl Config { exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, + replay_floor_unix: args.replay_floor, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, @@ -1164,7 +1225,7 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} session_policy={} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1176,6 +1237,7 @@ impl Config { self.heartbeat_interval_secs, self.subscribe_mode, self.dedup_mode, + self.session_policy, self.multiple_event_handling, self.ignore_self, self.context_message_limit, @@ -1489,6 +1551,7 @@ mod tests { initial_message: None, subscribe_mode: mode, dedup_mode: DedupMode::Queue, + session_policy: crate::scope::SessionPolicy::Channel, multiple_event_handling: MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -1513,6 +1576,7 @@ mod tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -2618,6 +2682,42 @@ channels = "ALL" assert!(result.is_empty()); } + // ── Session policy parsing + default ────────────────────────────────────── + + #[test] + fn test_session_policy_default_is_channel() { + // Ships dark: the default must be `channel` so thread scoping is opt-in + // and can be rolled back without code changes. + let args = CliArgs::parse_from(["buzz-acp", "--private-key", &"0".repeat(64)]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Channel); + } + + #[test] + fn test_session_policy_thread_flag_parses() { + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--session-policy", + "thread", + ]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread); + } + + #[test] + fn test_session_policy_env_var_parses() { + // The env fallback (`BUZZ_ACP_SESSION_POLICY`) must resolve to the same + // value as the flag; this is what the managed-agent runtime sets. + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--session-policy=thread", + ]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread); + assert_eq!(args.session_policy.to_string(), "thread"); + } + // ── Multiple-event-handling validation + default ────────────────────────── #[test] @@ -2674,9 +2774,9 @@ channels = "ALL" // ── Idle timeout constant + guard (PR #935) ─────────────────────────────── #[test] - fn default_idle_timeout_is_900_seconds() { + fn default_idle_timeout_is_1500_seconds() { // Lock the constant value so accidental changes are caught. - assert_eq!(DEFAULT_IDLE_TIMEOUT_SECS, 900); + assert_eq!(DEFAULT_IDLE_TIMEOUT_SECS, 1_500); } #[test] @@ -2696,6 +2796,45 @@ channels = "ALL" } } + #[test] + fn budget_ordering_invariant_shell_cap_plus_headroom_fits_within_idle_timeout() { + // Asserts the three-layer budget relationship introduced in PR #7185: + // buzz-dev-mcp MAX_TIMEOUT_MS (1 200 000 ms = 1 200s) + // ≤ buzz-agent BUZZ_AGENT_TOOL_TIMEOUT_SECS default (1 260s) + // < buzz-acp DEFAULT_IDLE_TIMEOUT_SECS (1 500s) + // + // The idle deadline must strictly outlast the agent tool timeout so a + // legitimately long-running tool call is killed by buzz-agent first (at + // 1 260s) rather than the ACP idle watchdog. The 240s gap gives the agent + // time to handle the timeout, emit a response, and reset the idle clock + // before the ACP connection dies. + // + // If any of these constants change the compiler catches the inversion here. + // Cross-crate constants are mirrored as literals; grep for PR #7185 to + // find the authoritative source if you need to update them. + const SHELL_CAP_MS: u64 = 1_200_000; // buzz-dev-mcp MAX_TIMEOUT_MS + const SHELL_CAP_SECS: u64 = SHELL_CAP_MS / 1_000; + const AGENT_TOOL_TIMEOUT_SECS: u64 = 1_260; // buzz-agent BUZZ_AGENT_TOOL_TIMEOUT_SECS default + + const { + // Shell cap must not exceed the agent's per-tool-call timeout. + assert!( + SHELL_CAP_SECS <= AGENT_TOOL_TIMEOUT_SECS, + "shell cap must be <= agent tool timeout" + ); + // Agent tool timeout must be strictly less than the ACP idle deadline. + assert!( + AGENT_TOOL_TIMEOUT_SECS < DEFAULT_IDLE_TIMEOUT_SECS, + "agent tool timeout must be < ACP idle timeout" + ); + // ACP idle timeout must remain below the max turn duration. + assert!( + DEFAULT_IDLE_TIMEOUT_SECS < DEFAULT_MAX_TURN_DURATION_SECS, + "ACP idle timeout must be < max turn duration" + ); + } + } + // --- BUZZ_ACP_ALLOWED_RESPOND_TO gate --- fn parse_allowed_respond_to(raw: &[&str]) -> Result, ConfigError> { @@ -2991,6 +3130,36 @@ channels = "ALL" assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); } + #[test] + fn scoped_session_title_keeps_short_root_even_when_names_fill_the_cap() { + let root = "abcdef01".repeat(8); + assert_eq!( + compose_scoped_session_title("Fizz", Some("buzz-dev"), Some(&root)), + "Fizz · #buzz-dev · abcdef01" + ); + assert_eq!( + compose_scoped_session_title("Fizz", None, Some(&root)), + "Fizz · abcdef01" + ); + assert_eq!( + compose_scoped_session_title("Fizz", Some("buzz-dev"), Some("abc")), + "Fizz · #buzz-dev · abc" + ); + for (agent, channel) in [ + ("🐝".repeat(80), "work".into()), + ("Fizz".into(), "🐝".repeat(100)), + ] { + let title = compose_scoped_session_title(&agent, Some(&channel), Some(&root)); + assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS); + assert!(title.ends_with(" · abcdef01")); + } + assert_eq!( + compose_scoped_session_title("Fizz", Some("buzz-dev"), None), + "Fizz · #buzz-dev" + ); + assert_eq!(compose_scoped_session_title("Fizz", None, None), "Fizz"); + } + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH /// must set `hide_env_values = true` to prevent credential leakage in --help. #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d1587744118..86b28c5a4a9 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -11,6 +11,7 @@ mod prompt_framing; mod prompt_project; mod queue; mod relay; +mod scope; mod setup_mode; mod usage; @@ -233,44 +234,525 @@ async fn is_owner_or_sibling( is_sibling } -/// Inbound author gate decision: does this author's event fire a turn? +/// Return the workflow owner attributed by a relay-signed workflow message. /// -/// Coarse security policy applied before subscription rules. Both `OwnerOnly` -/// and `Allowlist` accept the owner and same-owner siblings; `Allowlist` -/// additionally accepts the explicit external pubkey list. +/// `buzz:workflow-owner` alone is not authority: any ordinary event author can +/// forge custom tags. Attribution is accepted only for a cryptographically +/// valid kind:9 event signed by the active relay's NIP-11 `self` key, with +/// exactly one canonical workflow marker and owner pubkey. The current agent +/// must also have exactly one canonical `buzz:workflow-mention` tag; legacy `p` +/// tags are deliberately ignored as author-gate authority because workflows +/// retain an owner `p` tag for mentions-feed compatibility. +fn verified_workflow_owner( + event: &nostr::Event, + relay_self: Option<&str>, + agent_pubkey_hex: &str, +) -> Option { + if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE { + return None; + } + + let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?; + if event.pubkey != relay_self || event.verify().is_err() { + return None; + } + + let markers: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow")) + .collect(); + if markers.as_slice() != [["buzz:workflow", "true"]] { + return None; + } + + let owners: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow-owner")) + .collect(); + let [owner_tag] = owners.as_slice() else { + return None; + }; + let [_, owner_value] = owner_tag else { + return None; + }; + let owner = nostr::PublicKey::from_hex(owner_value).ok()?.to_hex(); + if owner_value.as_str() != owner { + return None; + } + + let agent_pubkey = nostr::PublicKey::from_hex(agent_pubkey_hex).ok()?.to_hex(); + let workflow_mentions: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow-mention")) + .collect(); + let mut mentioned_pubkeys = HashSet::with_capacity(workflow_mentions.len()); + for mention_tag in workflow_mentions { + let [_, mention_value] = mention_tag else { + return None; + }; + let mention = nostr::PublicKey::from_hex(mention_value).ok()?.to_hex(); + if mention_value.as_str() != mention || !mentioned_pubkeys.insert(mention) { + return None; + } + } + if !mentioned_pubkeys.contains(&agent_pubkey) { + return None; + } + + Some(owner) +} + +/// Resolve the author principal used by the inbound author gate. +fn effective_prompt_author( + event: &nostr::Event, + relay_self: Option<&str>, + agent_pubkey_hex: &str, +) -> String { + verified_workflow_owner(event, relay_self, agent_pubkey_hex) + .unwrap_or_else(|| event.pubkey.to_hex()) +} + +/// Owns the verified relay signing identity for a listener's lifetime and +/// applies the inbound author gate to each event. /// -/// # DM hardening (`is_dm`) +/// The relay identity is deliberately *not* a per-event parameter, and this +/// type deliberately lives in its own module with private fields so the only +/// way to obtain one is [`InboundAuthorGate::connect`], which loads the +/// identity. +/// +/// Two earlier revisions of this code were mutable-with-impunity: the first +/// threaded a local `Option` into every gate call, and the second kept +/// a free `evaluate_inbound_author_gate(.., relay_self, ..)` alongside the +/// method. In both cases a listener could be rewired to pass `None` — silently +/// disabling every delegated workflow wake — while all 848 tests stayed green. +/// Encapsulation, not a test, is what closes that seam: `InboundAuthorGate { +/// relay_self: None, .. }` is now a privacy error outside this module, and +/// dropping the load inside it fails the construction regressions. +mod inbound_author_gate { + use super::{ + effective_prompt_author, is_dm_channel, is_owner_or_sibling, pool, refresh_relay_self, + relay, OwnerCache, RespondTo, + }; + use std::collections::HashSet; + + pub(crate) struct InboundAuthorGateDecision { + pub(crate) effective_author: String, + pub(crate) allowed: bool, + pub(crate) is_dm: bool, + } + + /// An event that passed the complete listener author boundary. + /// + /// The event is moved into the gate before policy evaluation and can only + /// be recovered through this private-field capability. Both production + /// loops therefore have to consume the gate's verdict before they can use + /// or publish the event; replacing the call with a raw signer or a local + /// `allowed = true` no longer type-checks. + pub(crate) struct AuthorizedListenerEvent { + buzz_event: relay::BuzzEvent, + effective_author: String, + } + + impl AuthorizedListenerEvent { + pub(crate) fn into_parts(self) -> (relay::BuzzEvent, String) { + (self.buzz_event, self.effective_author) + } + } + + /// Apply the configured raw-author policy after trusted workflow attribution. + /// + /// This stays private to the gate module so neither listener can bypass + /// workflow attribution by calling the raw-signer policy directly. + async fn author_allowed( + respond_to: &RespondTo, + allowlist: &HashSet, + author: &str, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> bool { + if is_dm { + return match respond_to { + RespondTo::Nobody => false, + _ => is_owner_or_sibling(author, owner_cache, rest_client).await, + }; + } + match respond_to { + RespondTo::Anyone => true, + RespondTo::Nobody => false, + RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::Allowlist => { + allowlist.contains(author) + || is_owner_or_sibling(author, owner_cache, rest_client).await + } + } + } + + #[cfg(test)] + pub(super) async fn test_author_allowed( + respond_to: &RespondTo, + allowlist: &HashSet, + author: &str, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> bool { + author_allowed( + respond_to, + allowlist, + author, + is_dm, + owner_cache, + rest_client, + ) + .await + } + + pub(crate) struct InboundAuthorGate { + agent_pubkey_hex: String, + relay_self: Option, + // None means no authoritative NIP-11 result yet, including at startup. + refreshed_generation: Option, + } + + pub(crate) fn refresh_needed(refreshed_generation: Option, event_generation: u64) -> bool { + refreshed_generation.is_none_or(|generation| event_generation > generation) + } + + impl InboundAuthorGate { + /// Load the relay signing identity for a freshly connected listener. + pub(crate) async fn connect( + rest_client: &relay::RestClient, + agent_pubkey_hex: &str, + context: &str, + ) -> Self { + let (relay_self, completed) = refresh_relay_self(rest_client, None, context).await; + Self { + agent_pubkey_hex: agent_pubkey_hex.to_string(), + relay_self, + refreshed_generation: completed.then_some(0), + } + } + + /// Whether delegated workflow attribution is currently available. + /// + /// Test-only: production code never branches on this. + /// `refresh_relay_self` already logs why attribution is unavailable, and + /// every runtime path treats a missing identity by falling back to the + /// raw signer. + #[cfg(test)] + pub(crate) fn has_relay_identity(&self) -> bool { + self.relay_self.is_some() + } + + #[cfg(test)] + pub(crate) fn relay_identity_for_test(&self) -> Option<&str> { + self.relay_self.as_deref() + } + + /// Refresh relay identity, resolve channel trust, and apply trusted + /// workflow attribution and author policy for one listener event. + /// + /// Both production listeners call this exact boundary. Identity refresh + /// cannot be omitted independently of authorization; the raw-author + /// policy and relay identity are private to this module. + pub(crate) async fn evaluate_listener_event( + &mut self, + buzz_event: &relay::BuzzEvent, + respond_to: &RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + // Retry failed startup discovery on generation 0 as well as failed + // reconnect refreshes. Only an authoritative result completes the + // generation; transient failure retains the last verified key. + if refresh_needed(self.refreshed_generation, buzz_event.connection_generation) { + let (relay_self, completed) = + refresh_relay_self(rest_client, self.relay_self.take(), "listener").await; + self.relay_self = relay_self; + if completed { + self.refreshed_generation = Some(buzz_event.connection_generation); + } + } + let is_dm = is_dm_channel(buzz_event.channel_id, channel_info).await; + self.evaluate_with_channel_trust( + &buzz_event.event, + respond_to, + allowlist, + is_dm, + owner_cache, + rest_client, + ) + .await + } + + async fn evaluate_with_channel_trust( + &self, + event: &nostr::Event, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + let effective_author = + effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex); + let allowed = author_allowed( + respond_to, + allowlist, + &effective_author, + is_dm, + owner_cache, + rest_client, + ) + .await; + InboundAuthorGateDecision { + effective_author, + allowed, + is_dm, + } + } + + pub(crate) async fn authorize_listener_event( + &mut self, + buzz_event: relay::BuzzEvent, + respond_to: &RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, + rest_client: &relay::RestClient, + ) -> Option { + let decision = self + .evaluate_listener_event( + &buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await; + if !decision.allowed { + // warn, not debug: a refused request is a person who got no + // answer. At debug level this is invisible in practice, so the + // sender is stranded and the owner never learns anyone tried. + tracing::warn!( + channel_id = %buzz_event.channel_id, + raw_author = %buzz_event.event.pubkey.to_hex(), + effective_author = %decision.effective_author, + mode = %respond_to, + is_dm = decision.is_dm, + "inbound author gate — dropping event" + ); + return None; + } + Some(AuthorizedListenerEvent { + buzz_event, + effective_author: decision.effective_author, + }) + } + + #[cfg(test)] + pub(crate) async fn evaluate_for_test( + &self, + event: &nostr::Event, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + self.evaluate_with_channel_trust( + event, + respond_to, + allowlist, + is_dm, + owner_cache, + rest_client, + ) + .await + } + } +} + +use inbound_author_gate::{AuthorizedListenerEvent, InboundAuthorGate}; + +struct AuthorizedNormalListenerEvent(AuthorizedListenerEvent); + +struct NormalListenerIngress { + buzz_event: relay::BuzzEvent, + effective_author: String, + prompt_tag: String, +} + +impl AuthorizedNormalListenerEvent { + async fn match_subscription( + self, + rules: &[SubscriptionRule], + agent_pubkey_hex: &str, + ) -> Option { + let (buzz_event, effective_author) = self.0.into_parts(); + let matched = filter::match_event( + &buzz_event.event, + buzz_event.channel_id, + rules, + agent_pubkey_hex, + ) + .await?; + Some(NormalListenerIngress { + buzz_event, + effective_author, + prompt_tag: matched.prompt_tag, + }) + } +} + +struct QueuedNormalListenerEvent { + accepted: bool, + scope: scope::SessionScope, + effective_author: String, + event_id_hex: String, + event_for_steer: nostr::Event, + prompt_tag_for_steer: String, +} + +impl QueuedNormalListenerEvent { + fn mark_seen(&self, rest_client: &relay::RestClient) { + if !self.accepted { + return; + } + let rest_client = rest_client.clone(); + let event_id = self.event_id_hex.clone(); + tokio::spawn(async move { + pool::reaction_add(&rest_client, &event_id, "👀").await; + }); + } + + fn steer_or_interrupt( + self, + handling: MultipleEventHandling, + owner: Option<&str>, + pool: &mut AgentPool, + queue: &mut EventQueue, + steer_ack_tx: &mpsc::UnboundedSender, + ) { + if !self.accepted || !queue.is_scope_in_flight(&self.scope) { + return; + } + let Some(signal) = mode_gate_signal(handling, &self.effective_author, owner) else { + return; + }; + let native_attempted = matches!(signal, ControlSignal::Steer) + && try_native_steer( + pool, + queue, + self.scope.clone(), + self.event_for_steer, + self.prompt_tag_for_steer, + steer_ack_tx, + ); + if !native_attempted { + signal_in_flight_task_for_scope(pool, &self.scope, signal); + } + } +} + +impl NormalListenerIngress { + fn push( + self, + queue: &mut EventQueue, + session_scope: scope::SessionScope, + ) -> QueuedNormalListenerEvent { + let Self { + buzz_event, + effective_author, + prompt_tag, + } = self; + let event_id_hex = buzz_event.event.id.to_hex(); + let event_for_steer = buzz_event.event.clone(); + let prompt_tag_for_steer = prompt_tag.clone(); + let channel_id = buzz_event.channel_id; + let accepted = queue.push(QueuedEvent { + channel_id, + scope: session_scope.clone(), + event: buzz_event.event, + received_at: std::time::Instant::now(), + prompt_tag, + }); + QueuedNormalListenerEvent { + accepted, + scope: session_scope, + effective_author, + event_id_hex, + event_for_steer, + prompt_tag_for_steer, + } + } +} + +/// Apply the complete normal-listener author boundary for one relay event. /// -/// Clients auto-p-tag every DM participant, so in a DM *any* participant's -/// message looks like a mention and would fire a turn. Combined with -/// agent-initiated DMs (the agent can be asked to DM a third party), that -/// turns `anyone`/`allowlist` modes into transitive access grants: whoever -/// lands in a DM with the agent can prompt it. To close that hole, when -/// `is_dm` is true only the owner and cryptographically verified same-owner -/// siblings may fire a turn — the explicit allowlist and `anyone` mode do -/// NOT apply inside DMs. `Nobody` still drops everything. Callers must -/// resolve `is_dm` fail-closed: unknown channel type ⇒ treat as DM. -async fn author_allowed( +/// The event is consumed here, so the production loop cannot recover it except +/// from the gate's private authorized capability. +async fn authorize_normal_listener_event( + author_gate: &mut InboundAuthorGate, + buzz_event: relay::BuzzEvent, respond_to: &RespondTo, allowlist: &HashSet, - author: &str, - is_dm: bool, owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, rest_client: &relay::RestClient, -) -> bool { - if is_dm { - return match respond_to { - RespondTo::Nobody => false, - _ => is_owner_or_sibling(author, owner_cache, rest_client).await, - }; - } - match respond_to { - RespondTo::Anyone => true, - RespondTo::Nobody => false, - RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, - RespondTo::Allowlist => { - allowlist.contains(author) - || is_owner_or_sibling(author, owner_cache, rest_client).await +) -> Option { + author_gate + .authorize_listener_event( + buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await +} + +/// Refresh the relay signing identity, logging why delegated workflow +/// attribution is unavailable. A transient fetch error keeps the last verified +/// key so a reconnect blip cannot disable workflow wakes. That availability +/// tradeoff creates a bounded-by-success revocation window: a rotated-away key +/// remains trusted while NIP-11 refreshes keep failing, then is replaced or +/// cleared by the next successful response. Refresh runs at startup and before +/// authorization on a new or still-pending generation; a completed generation +/// is not refreshed again until a reconnect. +async fn refresh_relay_self( + rest_client: &relay::RestClient, + current: Option, + context: &str, +) -> (Option, bool) { + match rest_client.relay_self().await { + Ok(Some(pubkey)) => (Some(pubkey), true), + Ok(None) => { + tracing::warn!( + %context, + "relay NIP-11 document has no `self` key — workflow attribution remains fail-closed" + ); + (None, true) + } + Err(error) => { + tracing::warn!( + %context, + %error, + retaining_previous_identity = current.is_some(), + "failed to refresh relay NIP-11 identity" + ); + (current, false) } } } @@ -1306,8 +1788,13 @@ fn handle_cancel_turn_control( return; }; - let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - let status = if fired { "sent" } else { "no_active_turn" }; + let status = if pool.channel_control_is_ambiguous(channel_id) { + "ambiguous_target" + } else if signal_in_flight_task(pool, channel_id, ControlSignal::Cancel) { + "sent" + } else { + "no_active_turn" + }; if let Some(observer) = observer { observer.emit( "control_result", @@ -1321,6 +1808,7 @@ fn handle_cancel_turn_control( serde_json::json!({ "type": "cancel_turn", "status": status, + "requestId": payload.get("requestId"), }), ); } @@ -1370,7 +1858,11 @@ fn handle_switch_model_control( .values() .any(|m| m.channel_id == Some(channel_id)); - let status = if turn_in_flight { + let status = if pool.channel_control_is_ambiguous(channel_id) { + // The Desktop protocol names channels, not sessions. Never switch one + // arbitrary sibling and report a channel-wide success. + "ambiguous_target" + } else if turn_in_flight { // Busy path: deliver over the oneshot. `false` means the oneshot was // already consumed this turn (a prior cancel/interrupt) — the turn is // already ending, so the switch cannot land on it. @@ -1389,6 +1881,7 @@ fn handle_switch_model_control( } else { // Idle path: validate against the cached catalog before invalidating. match pool.switch_idle_agent_model(channel_id, model_id, request_id.clone()) { + IdleSwitchResult::AmbiguousTarget => "ambiguous_target", IdleSwitchResult::Switched => "switched", IdleSwitchResult::UnsupportedModel => "unsupported_model", IdleSwitchResult::NoIdleAgent => "no_active_turn", @@ -1568,6 +2061,9 @@ struct RespawnResult { /// `event_id` is the hex id of the single event the steer carried. struct SteerAckEvent { channel_id: Uuid, + /// Session scope of the steered event — the queue-side withhold/release + /// and deadline extension target this, not the whole channel. + scope: scope::SessionScope, event_id: String, /// `Ok` if the read loop sent any of the locked `SteerAck` variants. /// `Err` if the oneshot was dropped without a send — should not happen @@ -1894,6 +2390,63 @@ mod idle_pool_sleep_tests { } } +/// Oldest a caller-supplied replay floor may reach back from startup. Bounds +/// the stale-event burst when a spawn request sat around (e.g. the desktop +/// slept between the send and this spawn actually running). +const REPLAY_FLOOR_MAX_AGE_SECS: u64 = 15 * 60; + +/// Resolve the startup watermark from process-start time and an optional +/// replay floor (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`). +/// +/// A publish-first mention send publishes the triggering message BEFORE this +/// harness spawns, so the watermark must reach back to the send timestamp for +/// the first REQ (`since = watermark − 5s`) to replay that message. Floors +/// older than [`REPLAY_FLOOR_MAX_AGE_SECS`] clamp to that bound; floors in +/// the future clamp to `now` (a skewed sender must not push the watermark +/// forward past startup and re-open the blind spot the watermark closes). +fn startup_watermark_with_floor(now_unix: u64, replay_floor: Option) -> u64 { + match replay_floor { + Some(floor) => floor.clamp(now_unix.saturating_sub(REPLAY_FLOOR_MAX_AGE_SECS), now_unix), + None => now_unix, + } +} + +#[cfg(test)] +mod replay_floor_tests { + use super::{startup_watermark_with_floor, REPLAY_FLOOR_MAX_AGE_SECS}; + + const NOW: u64 = 1_700_000_000; + + #[test] + fn no_floor_keeps_startup_time() { + assert_eq!(startup_watermark_with_floor(NOW, None), NOW); + } + + #[test] + fn recent_floor_moves_watermark_back_to_the_send_timestamp() { + // The publish-first case: message sent 4s before the harness booted. + assert_eq!(startup_watermark_with_floor(NOW, Some(NOW - 4)), NOW - 4); + } + + #[test] + fn stale_floor_clamps_to_the_max_age_bound() { + assert_eq!( + startup_watermark_with_floor(NOW, Some(NOW - REPLAY_FLOOR_MAX_AGE_SECS - 1)), + NOW - REPLAY_FLOOR_MAX_AGE_SECS + ); + } + + #[test] + fn future_floor_is_ignored() { + assert_eq!(startup_watermark_with_floor(NOW, Some(NOW + 60)), NOW); + } + + #[test] + fn early_epoch_now_does_not_underflow() { + assert_eq!(startup_watermark_with_floor(10, Some(0)), 0); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -1991,10 +2544,24 @@ async fn tokio_main() -> Result<()> { // the initial subscribe_since for channels discovered at startup. The Subscribe // handler falls back to subscribe_since when last_seen is None, closing the // blind spot between "agents ready" and "first REQ sent". - let startup_watermark: u64 = std::time::SystemTime::now() + // + // A publish-first mention send passes the triggering message's send + // timestamp as a replay floor (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`): + // the message is already on the relay when this process spawns, so the + // watermark must reach back to it for the first REQ to replay it — however + // long the spawn took. + let now_unix: u64 = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); + let startup_watermark = startup_watermark_with_floor(now_unix, config.replay_floor_unix); + if let Some(floor) = config.replay_floor_unix { + tracing::info!( + floor, + startup_watermark, + "applying replay floor to startup watermark" + ); + } let pubkey_hex = config.keys.public_key().to_hex(); @@ -2019,6 +2586,10 @@ async fn tokio_main() -> Result<()> { tracing::info!("connected to relay at {}", config.relay_url); + let relay_rest_client = relay.rest_client(); + let mut author_gate_ctx = + InboundAuthorGate::connect(&relay_rest_client, &pubkey_hex, "startup").await; + relay .subscribe_membership_notifications() .await @@ -2204,10 +2775,17 @@ async fn tokio_main() -> Result<()> { team_instructions: config.team_instructions.clone(), base_prompt: if config.no_base_prompt { None - } else if let Some(content) = base_prompt_content { - Some(Box::leak(content.into_boxed_str())) } else { - Some(include_str!("base_prompt.md")) + // Build standing context once under the configured policy, before + // any session/new. Both modern ACP and legacy first-turn framing + // consume this same assembled base (including custom base files). + Some( + config.session_policy.append_session_model( + base_prompt_content + .as_deref() + .unwrap_or(include_str!("base_prompt.md")), + ), + ) }, heartbeat_prompt: config.heartbeat_prompt.clone(), cwd, @@ -2262,7 +2840,7 @@ async fn tokio_main() -> Result<()> { } else { None }; - let mut typing_channels: HashMap = HashMap::new(); + let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; // (channel, author) pairs already told they are outside the author gate. @@ -2482,10 +3060,10 @@ 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 + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -2534,10 +3112,10 @@ 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 + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } @@ -2728,7 +3306,9 @@ async fn tokio_main() -> Result<()> { // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); - typing_channels.remove(&ch); + // Drop every thread scope's typing entry for + // the removed channel. + typing_channels.retain(|scope, _| scope.channel_id() != ch); // Best-effort: clean up 👀 on drained events. // Note: the relay revokes membership before // emitting the notification, so this DELETE may @@ -2800,21 +3380,36 @@ async fn tokio_main() -> Result<()> { &pubkey_hex, ); if is_cancel { - if let Some(owner) = owner_cache.get() { - if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( - &mut pool, - buzz_event.channel_id, - ControlSignal::Cancel, + let from_owner = owner_cache.get().is_some_and(|owner| { + buzz_event.event.pubkey.to_hex() == *owner + }); + if from_owner { + // Scope-exact: an owner's !cancel in thread A + // must cancel thread A's turn, never a sibling + // thread running in the same channel. Under + // the default channel policy the scope is the + // channel's sole conversation, so this is + // byte-for-byte the prior behavior. + let scope = scope::SessionScope::derive( + config.session_policy, + buzz_event.channel_id, + is_dm_channel(buzz_event.channel_id, &ctx.channel_info) + .await, + &buzz_event.event, + ); + let fired = signal_in_flight_task_for_scope( + &mut pool, + &scope, + ControlSignal::Cancel, + ); + if !fired { + tracing::warn!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + "!cancel received but no in-flight task — no-op" ); - if !fired { - tracing::warn!( - channel_id = %buzz_event.channel_id, - "!cancel received but no in-flight task — no-op" - ); - } - continue; // consume event — do NOT push to queue } + continue; // consume event — do NOT push to queue } // Not from owner — fall through to normal prompt handling. } @@ -2838,28 +3433,44 @@ async fn tokio_main() -> Result<()> { &pubkey_hex, ); if is_rotate { - if let Some(owner) = owner_cache.get() { - if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( - &mut pool, - buzz_event.channel_id, - ControlSignal::Rotate, + let from_owner = owner_cache.get().is_some_and(|owner| { + buzz_event.event.pubkey.to_hex() == *owner + }); + if from_owner { + // Scope-exact: rotate only the thread the + // owner's !rotate belongs to. Under the + // default channel policy the scope is the + // channel's sole conversation, matching the + // prior channel-wide rotate. + let scope = scope::SessionScope::derive( + config.session_policy, + buzz_event.channel_id, + is_dm_channel(buzz_event.channel_id, &ctx.channel_info) + .await, + &buzz_event.event, + ); + let fired = signal_in_flight_task_for_scope( + &mut pool, + &scope, + ControlSignal::Rotate, + ); + if fired { + tracing::info!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + "!rotate received — cancelling in-flight turn and rotating session" + ); + } else { + let invalidated = + pool.invalidate_scope_session(&scope); + tracing::info!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + invalidated, + "!rotate received — invalidated idle session for scope" ); - if fired { - tracing::info!( - channel_id = %buzz_event.channel_id, - "!rotate received — cancelling in-flight turn and rotating session" - ); - } else { - let invalidated = pool.invalidate_channel_sessions(buzz_event.channel_id); - tracing::info!( - channel_id = %buzz_event.channel_id, - invalidated, - "!rotate received — invalidated idle channel session(s)" - ); - } - continue; // consume event — do NOT push to queue } + continue; // consume event — do NOT push to queue } // Not from owner — fall through to normal prompt handling. } @@ -2875,165 +3486,117 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. - { - let author = buzz_event.event.pubkey.to_hex(); - // DM hardening: resolve channel type (fail-closed - // to DM) so allowlist/anyone modes cannot be - // exercised by non-owner authors inside DMs. - let is_dm = - is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; - let allowed = author_allowed( - &config.respond_to, - &config.respond_to_allowlist, - &author, - is_dm, - &owner_cache, - &ctx.rest_client, - ) - .await; - if !allowed { - // warn, not debug: a refused request is a - // person who got no answer. At debug level - // this is invisible in practice, so the - // sender is stranded and the owner never - // learns anyone tried. - tracing::warn!( - channel_id = %buzz_event.channel_id, - author = %author, - mode = %config.respond_to, - is_dm, - "inbound author gate — refusing event" - ); - - if remember_gate_notice( - &mut gate_notified, - (buzz_event.channel_id, author.clone()), - ) { - // Mention the owner in channels so they - // learn through the normal mention - // path, in the project where the - // request arrived. Never in a DM: that - // would disclose the owner's pubkey to - // a stranger. - let mentions: Vec = if is_dm { - Vec::new() - } else { - owner_cache - .get() - .map(|o| vec![o.to_string()]) - .unwrap_or_default() - }; - let content = author_gate_notice_text(is_dm); - let thread_tags = - queue::parse_thread_tags(&buzz_event.event); - let rest = ctx.rest_client.clone(); - let channel_id = buzz_event.channel_id; - tokio::spawn(async move { - pool::post_notice( - &rest, - channel_id, - &thread_tags, - &content, - &mentions, - ) - .await; - }); - } - continue; - } - } - - let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; - let prompt_tag = match matched { - Some(m) => m.prompt_tag, - None => { - tracing::debug!(channel_id = %buzz_event.channel_id, kind = buzz_event.event.kind.as_u16(), "event matched no rule — dropping"); - continue; + // The gate consumes the event, so capture what the + // drop logs and the notice need before it does. + let inbound_channel_id = buzz_event.channel_id; + let inbound_kind = buzz_event.event.kind.as_u16(); + let inbound_author = buzz_event.event.pubkey.to_hex(); + let inbound_thread_tags = queue::parse_thread_tags(&buzz_event.event); + let Some(authorized_event) = authorize_normal_listener_event( + &mut author_gate_ctx, + buzz_event, + &config.respond_to, + &config.respond_to_allowlist, + &owner_cache, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + else { + // The gate logs the drop at debug, which leaves + // the sender waiting on an answer that will never + // come and the owner unaware anyone tried. One + // notice per (channel, author) says so out loud + // without letting a noisy author turn the agent + // into a flooder. + if remember_gate_notice( + &mut gate_notified, + (inbound_channel_id, inbound_author), + ) { + let is_dm = + is_dm_channel(inbound_channel_id, &ctx.channel_info).await; + // Never mention the owner in a DM: that would + // disclose their pubkey to a stranger. + let mentions = if is_dm { + Vec::new() + } else { + owner_mentions(config.agent_owner.as_deref()) + }; + let content = author_gate_notice_text(is_dm); + let rest = ctx.rest_client.clone(); + tokio::spawn(async move { + pool::post_notice( + &rest, + inbound_channel_id, + &inbound_thread_tags, + &content, + &mentions, + ) + .await; + }); } + continue; + }; + let Some(ingress) = + AuthorizedNormalListenerEvent(authorized_event) + .match_subscription(&rules, &pubkey_hex) + .await + else { + tracing::debug!( + channel_id = %inbound_channel_id, + kind = inbound_kind, + "authorized event matched no rule — dropping" + ); + continue; }; - // Capture author pubkey before queue.push() moves - // buzz_event.event (needed for mode gate below). - let author_hex = buzz_event.event.pubkey.to_hex(); - let event_id_hex = buzz_event.event.id.to_hex(); - // Clone for the non-cancelling steer fork, which - // needs the event to render the steer body. The - // clone is unconditional because we don't know - // yet whether the mode gate will demand a steer - // — checking `multiple_event_handling` here - // would couple the queueing path to the mode - // and break the existing invariant that every - // accepted event goes through `queue.push` - // first. `nostr::Event::clone` is cheap (Arc- - // backed payload) so the cost is negligible. - let event_for_steer = buzz_event.event.clone(); - let prompt_tag_for_steer = prompt_tag.clone(); - let accepted = queue.push(QueuedEvent { - channel_id: buzz_event.channel_id, - event: buzz_event.event, - received_at: std::time::Instant::now(), - prompt_tag, - }); + // Derive the session scope once, at admission, from + // the operator policy, DM status, and NIP-10 thread + // tags. Under the default `channel` policy this is + // always a conversation scope, preserving today's + // channel-keyed routing. Telemetry only for now — + // queue/pool partitioning by scope lands in a + // follow-up (see ticket outline steps 2–4). + let session_scope = scope::SessionScope::derive( + config.session_policy, + ingress.buzz_event.channel_id, + is_dm_channel( + ingress.buzz_event.channel_id, + &ctx.channel_info, + ) + .await, + &ingress.buzz_event.event, + ); + tracing::debug!( + channel_id = %session_scope.channel_id(), + scope = %session_scope.telemetry_label(), + thread_scoped = session_scope.is_thread(), + thread_root = session_scope.root_event_id().unwrap_or("-"), + policy = %config.session_policy, + "admitted event — resolved session scope" + ); + let queued = ingress.push(&mut queue, session_scope); // 👀 — immediate "seen" reaction, only if the event // was actually queued (not dropped by DedupMode::Drop). // Fire-and-forget: on rare fast-failure paths the // guard's cleanup may race with this add, leaving a // cosmetic stale 👀. Acceptable — see ReactionGuard docs. - if accepted { - let rc = ctx.rest_client.clone(); - let eid = event_id_hex.clone(); - tokio::spawn(async move { - pool::reaction_add(&rc, &eid, "👀").await; - }); - } - // Event is already queued. If mode requires it AND - // the channel has an in-flight task, fire cancel — - // OR take the non-cancelling (ACP steer) fork for Steer signals. - if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { - // Author eligibility (owner ∪ allowlist ∪ siblings) - // is already enforced by the inbound author gate - // above, so the mid-turn signal fires for every - // event that reaches here. - let signal = mode_gate_signal( - config.multiple_event_handling, - &author_hex, - owner_cache.get(), - ); - if let Some(signal) = signal { - // Non-cancelling fork: when the mode - // wants a Steer, attempt the - // non-cancelling path first. On accept, - // withhold the queued event and spawn an - // ack watcher; the main loop's - // `PoolEvent::SteerAck` arm decides - // success/release/fallback. On reject - // (including agents that advertise no - // steer transport at all), fall through - // to the universal cancel+merge `Steer` - // signal so the event still reaches the - // agent. - let native_attempted = matches!(signal, ControlSignal::Steer) - && try_native_steer( - &mut pool, - &mut queue, - buzz_event.channel_id, - event_for_steer, - prompt_tag_for_steer, - &steer_ack_tx, - ); - if !native_attempted { - signal_in_flight_task( - &mut pool, - buzz_event.channel_id, - signal, - ); - } - } - } + queued.mark_seen(&ctx.rest_client); + // Event is already queued. The authorized ingress + // retains its verified author, resolved scope, and + // event data through the optional steer/interrupt + // decision. + queued.steer_or_interrupt( + config.multiple_event_handling, + owner_cache.get(), + &mut pool, + &mut queue, + &steer_ack_tx, + ); if pool_ready { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -3130,10 +3693,10 @@ async fn tokio_main() -> Result<()> { tracing::debug!("heartbeat_skipped_pool_not_ready"); } else if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } else if pool.any_idle() { dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); @@ -3172,7 +3735,8 @@ async fn tokio_main() -> Result<()> { // Use try_publish (non-blocking) for typing indicators — // they're ephemeral and must not block the main loop during // relay reconnection (#35). - for (&ch, thread_tags) in &typing_channels { + for (scope, thread_tags) in &typing_channels { + let ch = scope.channel_id(); if let Ok(event) = relay.build_typing_event( ch, thread_tags.root_event_id.as_deref(), @@ -3207,9 +3771,11 @@ async fn tokio_main() -> Result<()> { match pool_event { Some(PoolEvent::Result(result)) => { - // Stop typing indicator for the completed channel. - if let PromptSource::Channel(ch) = &result.source { - typing_channels.remove(ch); + // Stop the typing indicator for the completed turn's exact scope, + // not the whole channel — a sibling thread still running in the + // same channel must keep its indicator. + if let Some(scope) = result.source.scope() { + typing_channels.remove(scope); } if handle_prompt_result( &mut pool, @@ -3242,10 +3808,10 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Panic(join_error)) => { @@ -3267,14 +3833,15 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::SteerAck(SteerAckEvent { channel_id, + scope, event_id, ack, })) => { @@ -3388,12 +3955,8 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if let Ok(pool::SteerAck::Success { session_id }) = &ack { - queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); - if !pool.record_successful_steer( - channel_id, - event_id.clone(), - session_id.clone(), - ) { + queue.extend_in_flight_deadline(&scope, config.max_turn_duration_secs); + if !pool.record_successful_steer(&scope, event_id.clone(), session_id.clone()) { tracing::warn!( channel = %channel_id, event_id = %event_id, @@ -3402,18 +3965,20 @@ async fn tokio_main() -> Result<()> { } } if drop_withheld { - queue.remove_event(channel_id, &event_id); + queue.remove_event(&scope, &event_id); } if release_withheld { - queue.release_native_steer(channel_id, &event_id); + queue.release_native_steer(&scope, &event_id); } if signal_fallback { // Universal cancel+merge fallback. Note: the // queued event has already been released to the - // front of `queues[channel_id]`, so the cancel - // will pick it up as part of the merged batch and - // re-prompt the agent. - signal_in_flight_task(&mut pool, channel_id, ControlSignal::Steer); + // front of `queues[scope]`, so the cancel will pick + // it up as part of the merged batch and re-prompt the + // agent. Scope-exact so the fallback cancels the + // steered event's OWN thread, not a sibling thread + // in the same channel. + signal_in_flight_task_for_scope(&mut pool, &scope, ControlSignal::Steer); } // After releasing a withheld event, give dispatch a chance // to re-flush. If the prompt is still in flight, the @@ -3422,10 +3987,10 @@ 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 + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Wake(attempt, result)) => { @@ -3450,10 +4015,10 @@ async fn tokio_main() -> Result<()> { "ready", None, ); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Err(error) => { @@ -3650,12 +4215,25 @@ fn mode_gate_signal( } /// Send a control signal to the in-flight task for `channel_id`. +/// +/// Channel-targeted: refuses channels with multiple session scopes. Used only +/// by desktop observer frames (`cancel_turn` / `switch_model`), which carry a +/// bare `channelId` and no thread context. Every thread-aware +/// path — mid-turn steering/interruption and the owner `!cancel` / `!rotate` +/// commands, whose triggering event carries NIP-10 thread tags — uses +/// [`signal_in_flight_task_for_scope`], which targets one exact +/// [`scope::SessionScope`] so a signal for thread A can never hit thread B +/// running in the same channel. +/// /// Returns `true` if a signal was sent, `false` if no in-flight task was found. fn signal_in_flight_task( pool: &mut AgentPool, channel_id: uuid::Uuid, mode: ControlSignal, ) -> bool { + if pool.channel_control_is_ambiguous(channel_id) { + return false; + } let entry = pool .task_map_mut() .values_mut() @@ -3671,18 +4249,51 @@ fn signal_in_flight_task( false } -/// Attempt the non-cancelling (ACP) steer for a freshly-queued event. +/// Send a control signal to the in-flight task for one exact session scope. /// -/// Caller invariants: -/// - `event` has already been pushed into `EventQueue::queues[channel_id]` -/// via [`EventQueue::push`] — its `event.id` must still be locatable -/// there so [`EventQueue::mark_native_steer_pending`] can move it to the -/// side table. -/// - `multiple_event_handling` resolved to `ControlSignal::Steer`; this -/// function is the non-cancelling fork of that signal. +/// The scope-precise counterpart of [`signal_in_flight_task`]: mid-turn +/// steer/interrupt must target the thread the triggering event belongs to, not +/// “whichever task the channel happens to have first” — otherwise two threads +/// running concurrently in one channel could steer each other. /// -/// Returns `true` if the native attempt was accepted by the read loop -/// (capacity-1 mpsc `try_send` succeeded, event withheld synchronously, +/// Returns `true` if a signal was sent, `false` if no in-flight task matched. +fn signal_in_flight_task_for_scope( + pool: &mut AgentPool, + scope: &scope::SessionScope, + mode: ControlSignal, +) -> bool { + let entry = pool + .task_map_mut() + .values_mut() + .find(|m| m.scope.as_ref() == Some(scope)); + + if let Some(meta) = entry { + if let Some(tx) = meta.control_tx.take() { + tracing::info!( + channel = %scope.channel_id(), + scope = %scope.telemetry_label(), + ?mode, + "control signal sent to in-flight task (scope-exact)" + ); + let _ = tx.send(mode); + return true; + } + } + false +} + +/// Attempt the non-cancelling (ACP) steer for a freshly-queued event. +/// +/// Caller invariants: +/// - `event` has already been pushed into `EventQueue::queues[channel_id]` +/// via [`EventQueue::push`] — its `event.id` must still be locatable +/// there so [`EventQueue::mark_native_steer_pending`] can move it to the +/// side table. +/// - `multiple_event_handling` resolved to `ControlSignal::Steer`; this +/// function is the non-cancelling fork of that signal. +/// +/// Returns `true` if the native attempt was accepted by the read loop +/// (capacity-1 mpsc `try_send` succeeded, event withheld synchronously, /// ack watcher spawned). On `true` the caller MUST NOT issue the /// universal cancel+merge `ControlSignal::Steer` fallback — the watcher /// will issue it from the ack arm if the native attempt fails. @@ -3698,11 +4309,12 @@ fn signal_in_flight_task( fn try_native_steer( pool: &mut AgentPool, queue: &mut EventQueue, - channel_id: uuid::Uuid, + scope: scope::SessionScope, event: nostr::Event, prompt_tag: String, steer_ack_tx: &mpsc::UnboundedSender, ) -> bool { + let channel_id = scope.channel_id(); // Build the steer body: framing strings come from // `queue::native_steer_framing()` (Eva's drift-proof requirement — // native and cancel+merge fallback share these so the agent gets the @@ -3738,14 +4350,14 @@ fn try_native_steer( ack_tx, }; - match pool.send_steer(channel_id, request) { + match pool.send_steer(&scope, request) { Ok(()) => { // Withhold the queued event synchronously BEFORE spawning // the watcher: this closes the race where `mark_complete` // clears `in_flight_channels` and a stray `flush_next` could // re-deliver the event via normal dispatch. See // `EventQueue::mark_native_steer_pending` docs at queue.rs:606. - let withheld = queue.mark_native_steer_pending(channel_id, &event_id_hex); + let withheld = queue.mark_native_steer_pending(&scope, &event_id_hex); if !withheld { // Race: the event was already drained out of the queue // before we got here (e.g. a concurrent flush picked it @@ -3763,10 +4375,12 @@ fn try_native_steer( } let ack_tx_clone = steer_ack_tx.clone(); let event_id_for_watcher = event_id_hex.clone(); + let scope_for_watcher = scope.clone(); tokio::spawn(async move { let ack = ack_rx.await; let _ = ack_tx_clone.send(SteerAckEvent { channel_id, + scope: scope_for_watcher, event_id: event_id_for_watcher, ack, }); @@ -3792,31 +4406,56 @@ fn dispatch_pending( queue: &mut EventQueue, ctx: &Arc, last_activity: &mut tokio::time::Instant, -) -> Vec<(Uuid, ThreadTags)> { +) -> Vec<(scope::SessionScope, ThreadTags)> { + // Keyed by the exact session scope, not the channel: two threads dispatching + // concurrently in one channel get distinct typing entries so completing one + // never clears the other's indicator. let mut dispatched_channels = Vec::new(); + // Batches held back this cycle because the worker that owns their thread's + // session is busy. They stay flushed-out of the queue (in-flight) until we + // release them at the end so `flush_next` cannot re-pick them mid-loop; + // releasing requeues them so the next dispatch (when the owner returns) + // reuses that exact session instead of forking a duplicate. + let mut held: Vec = Vec::new(); loop { let batch = match queue.flush_next() { Some(b) => b, None => break, }; let channel_id = batch.channel_id; + let scope = batch.scope.clone(); + // Authoritative affinity: if the worker that owns this thread's session + // is checked out (busy on another turn), hold the batch rather than let + // an idle worker open a second session for the same thread. + if pool.should_hold_for_busy_owner(&scope) { + tracing::debug!( + channel = %channel_id, + scope = %scope.telemetry_label(), + "holding batch — session owner busy; awaiting its return to avoid duplicate session" + ); + held.push(batch); + continue; + } let typing_scope = batch .events .last() .map(|event| queue::parse_thread_tags(&event.event)) .unwrap_or_default(); - let affinity_hit = pool.has_session_for(channel_id); - let mut agent = match pool.try_claim(Some(channel_id)) { + // Scope-level affinity: reuse the worker that already holds THIS + // thread's provider session so a temporarily busy worker cannot cause + // another to open a duplicate session for the same thread. + let affinity_hit = pool.has_session_for(&scope); + let mut agent = match pool.try_claim(Some(&scope)) { Some(a) => a, None => { let pending = queue.pending_channels(); tracing::debug!(pending_channels = pending, "pool_exhausted"); queue.requeue_preserve_timestamps(batch); - queue.mark_complete(channel_id); + queue.mark_complete(&scope); break; } }; - tracing::debug!(agent = agent.index, channel = %channel_id, affinity_hit, "agent_claimed"); + tracing::debug!(agent = agent.index, channel = %channel_id, scope = %scope.telemetry_label(), affinity_hit, "agent_claimed"); let recoverable_batch = match ctx.dedup_mode { DedupMode::Queue => Some(batch.clone()), @@ -3864,6 +4503,7 @@ fn dispatch_pending( pool::TaskMeta { agent_index, channel_id: Some(channel_id), + scope: Some(scope.clone()), turn_id, recoverable_batch, control_tx: Some(control_tx), @@ -3871,9 +4511,21 @@ fn dispatch_pending( successful_steer_deliveries: HashSet::new(), }, ); - dispatched_channels.push((channel_id, typing_scope)); + // Record this worker as the scope's session owner so a later dispatch + // while it is busy holds instead of forking a duplicate session. + pool.record_scope_owner(scope.clone(), agent_index); + dispatched_channels.push((scope, typing_scope)); *last_activity = tokio::time::Instant::now(); } + // Release held batches back to the queue (owner busy). They were flushed + // out (in-flight) so they could not be re-picked above; requeue preserves + // their timestamps and mark_complete clears the in-flight marker, leaving + // them queued for the next dispatch when the owner frees up. + for batch in held { + let scope = batch.scope.clone(); + queue.requeue_preserve_timestamps(batch); + queue.mark_complete(scope); + } tracing::debug!( dispatched = dispatched_channels.len(), queue_depth = queue.pending_channels(), @@ -4052,19 +4704,20 @@ fn handle_prompt_result( pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); - if let PromptSource::Channel(channel_id) = &result.source { + if let PromptSource::Channel(scope) = &result.source { // The task may have invalidated this session before returning. Never // resurrect delivery state for a dead session; its replacement must // receive fresh standing context and history. - if let Some(live_session_id) = result.agent.state.sessions.get(channel_id).cloned() { + if let Some(live_session_id) = result.agent.state.sessions.get(scope).cloned() { let event_ids = successful_steer_deliveries .into_iter() .filter(|delivery| delivery.session_id == live_session_id) .map(|delivery| delivery.event_id); + let scope = scope.clone(); result .agent .state - .mark_channel_delivery_success(*channel_id, false, event_ids); + .mark_scope_delivery_success(scope, false, event_ids); } } @@ -4204,7 +4857,7 @@ fn handle_prompt_result( } match &result.source { - PromptSource::Channel(ch) => queue.mark_complete(*ch), + PromptSource::Channel(scope) => queue.mark_complete(scope.clone()), PromptSource::Heartbeat => *heartbeat_in_flight = false, } @@ -4240,10 +4893,7 @@ fn handle_prompt_result( .to_string(); let harness_pid = std::process::id(); - let channel_id = match &result.source { - PromptSource::Channel(ch) => Some(*ch), - PromptSource::Heartbeat => None, - }; + let channel_id = result.source.channel_id(); let turn_id = result.turn_id.clone(); let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { @@ -4453,7 +5103,7 @@ fn recover_panicked_agent( join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -4484,8 +5134,23 @@ fn recover_panicked_agent( } if let Some(ch) = meta.channel_id { - queue.mark_complete(ch); - typing_channels.remove(&ch); + // Clear the EXACT session scope, not the channel. Passing a bare + // channel id would resolve to `Conversation(channel_id)` via IntoScope + // and, under thread policy, leave the actual `Thread(...)` entry wedged + // in-flight until the ~2h backstop deadline — blocking the batch we + // just requeued. `meta.scope` is the authoritative in-flight scope. + match &meta.scope { + Some(scope) => { + // Clear the panicked turn's exact scope so a sibling thread in + // the same channel keeps its typing indicator. + typing_channels.remove(scope); + queue.mark_complete(scope.clone()); + } + None => { + typing_channels.retain(|scope, _| scope.channel_id() != ch); + queue.mark_complete(ch); + } + } tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); } else { *heartbeat_in_flight = false; @@ -4551,7 +5216,7 @@ fn drain_ready_join_results( config: &Config, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -4622,6 +5287,7 @@ fn dispatch_heartbeat( pool::TaskMeta { agent_index, channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, @@ -5434,6 +6100,7 @@ mod owner_control_command_tests { pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -5460,6 +6127,181 @@ mod owner_control_command_tests { )); } + fn thread_scope(channel_id: Uuid, root: &str) -> scope::SessionScope { + scope::SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + fn insert_task_meta( + pool: &mut AgentPool, + agent_index: usize, + scope: scope::SessionScope, + control_tx: tokio::sync::oneshot::Sender, + ) { + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index, + channel_id: Some(scope.channel_id()), + scope: Some(scope), + turn_id: "t".to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + } + + #[tokio::test] + async fn observer_channel_controls_reject_sibling_sessions_without_signalling() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let a = thread_scope(ch, &"a".repeat(64)); + let b = thread_scope(ch, &"b".repeat(64)); + let (tx_a, mut rx_a) = tokio::sync::oneshot::channel(); + let (tx_b, mut rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, a.clone(), tx_a); + insert_task_meta(&mut pool, 1, b.clone(), tx_b); + let observer = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "channelId": ch.to_string(), "modelId": "new-model", "requestId": "pick-1", + }); + + handle_cancel_turn_control(&payload, &mut pool, Some(&observer)); + handle_switch_model_control(&payload, &mut pool, Some(&observer)); + let results = observer.snapshot(); + assert_eq!(results.len(), 2); + for result in results { + assert_eq!(result.payload["status"], "ambiguous_target"); + assert_eq!(result.payload["requestId"], "pick-1"); + assert_eq!(result.channel_id, Some(ch.to_string())); + } + assert_eq!( + rx_a.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + assert_eq!( + rx_b.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + + // Completion does not make a channel-wide model switch safe: the + // sibling's retained session is still a distinct target. + pool.record_scope_owner(a, 0); + pool.record_scope_owner(b, 1); + pool.task_map_mut().clear(); + assert_eq!( + pool.switch_idle_agent_model(ch, "new-model", None), + IdleSwitchResult::AmbiguousTarget + ); + assert!(!pool.channel_control_is_ambiguous(Uuid::new_v4())); + } + + #[tokio::test] + async fn observer_channel_controls_allow_one_scope_and_ignore_other_channels() { + for signal in [ + ControlSignal::Cancel, + ControlSignal::SwitchModel { + model_id: "new-model".into(), + request_id: Some("pick-1".into()), + }, + ] { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let scope = scope::SessionScope::Conversation { channel_id: ch }; + pool.record_scope_owner(scope.clone(), 0); + pool.record_scope_owner(thread_scope(Uuid::new_v4(), &"a".repeat(64)), 1); + let (tx, rx) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, scope, tx); + let observer = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "channelId": ch.to_string(), "modelId": "new-model", "requestId": "pick-1", + }); + match &signal { + ControlSignal::Cancel => { + handle_cancel_turn_control(&payload, &mut pool, Some(&observer)) + } + _ => handle_switch_model_control(&payload, &mut pool, Some(&observer)), + } + assert_eq!(rx.await.unwrap(), signal); + assert_eq!(observer.snapshot()[0].payload["status"], "sent"); + } + } + + // Fix #2: mid-turn steer/interrupt must target the exact thread scope, not + // “the first task in the channel” — two threads in one channel must not + // interrupt each other. + #[tokio::test] + async fn signal_in_flight_task_for_scope_targets_only_matching_thread() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let (tx_a, rx_a) = tokio::sync::oneshot::channel(); + let (tx_b, rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, ta.clone(), tx_a); + insert_task_meta(&mut pool, 1, tb.clone(), tx_b); + + // Signalling thread A must reach A's task only. + assert!(signal_in_flight_task_for_scope( + &mut pool, + &ta, + ControlSignal::Steer + )); + assert_eq!(rx_a.await.unwrap(), ControlSignal::Steer); + + // Thread B's control channel is untouched (still open, no signal). + assert!(signal_in_flight_task_for_scope( + &mut pool, + &tb, + ControlSignal::Interrupt + )); + assert_eq!(rx_b.await.unwrap(), ControlSignal::Interrupt); + + // A scope with no in-flight task returns false. + assert!(!signal_in_flight_task_for_scope( + &mut pool, + &thread_scope(ch, &"c".repeat(64)), + ControlSignal::Steer + )); + } + + // Fix #1: a thread must not get a second provider session when the worker + // that owns its session is busy on another turn. + #[tokio::test] + async fn busy_session_owner_holds_batch_instead_of_forking_session() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + + // Worker 0 owns thread A's session and is currently busy running B. + pool.record_scope_owner(ta.clone(), 0); + let (tx_b, _rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, tb.clone(), tx_b); + + // A new A message must be HELD (owner busy, no idle worker holds A). + assert!( + pool.should_hold_for_busy_owner(&ta), + "owner busy => hold to avoid a duplicate session" + ); + + // A brand-new thread with no recorded owner is never held. + assert!(!pool.should_hold_for_busy_owner(&thread_scope(ch, &"d".repeat(64)))); + + // Channel-wide session invalidation prunes the directory so a stale + // owner can never strand a held batch. + pool.invalidate_channel_sessions(ch); + assert!( + !pool.should_hold_for_busy_owner(&ta), + "owner directory pruned on channel invalidation" + ); + } + #[test] fn project_owner_control_signs_only_addressable_project_events() { let keys = Keys::generate(); @@ -5477,94 +6319,1358 @@ mod owner_control_command_tests { created_at: Some(1), tags: vec![vec!["d".to_string(), "repository".to_string()]], }, - ], - &keys, - ) - .expect("valid project events"); + ], + &keys, + ) + .expect("valid project events"); + + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event.pubkey == keys.public_key())); + assert!(events.iter().all(|event| event.verify().is_ok())); + } + + #[test] + fn project_owner_control_rejects_arbitrary_or_unaddressed_events() { + let keys = Keys::generate(); + let arbitrary = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 1, + content: String::new(), + created_at: None, + tags: vec![vec!["d".to_string(), "project".to_string()]], + }], + &keys, + ); + assert!(arbitrary.is_err()); + + let unaddressed = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: None, + tags: vec![], + }], + &keys, + ); + assert!(unaddressed.is_err()); + } +} + +#[cfg(test)] +mod owner_cache_tests { + use super::*; + + #[test] + fn new_with_some_caches_immediately() { + let cache = OwnerCache::new(Some("abcd".into())); + assert_eq!(cache.get(), Some("abcd")); + } + + #[test] + fn new_with_none_returns_none() { + let cache = OwnerCache::new(None); + assert!(cache.get().is_none()); + } + + #[test] + fn get_returns_cached_value() { + let cache = OwnerCache::new(Some("ab".repeat(32))); + assert_eq!(cache.get(), Some("ab".repeat(32)).as_deref()); + } +} + +#[cfg(test)] +mod workflow_owner_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn workflow_event( + signer: &Keys, + owner: Option<&str>, + marker_tags: &[&[&str]], + workflow_mentions: &[&[&str]], + p_tags: &[&str], + ) -> nostr::Event { + let mut tags = Vec::new(); + for marker in marker_tags { + tags.push(Tag::parse(marker.iter().copied()).expect("workflow marker")); + } + if let Some(owner) = owner { + tags.push(Tag::parse(["buzz:workflow-owner", owner]).expect("workflow owner tag")); + } + for mention in workflow_mentions { + tags.push(Tag::parse(mention.iter().copied()).expect("workflow mention tag")); + } + for recipient in p_tags { + tags.push(Tag::parse(["p", *recipient]).expect("p tag")); + } + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags(tags) + .sign_with_keys(signer) + .expect("signed event") + } + + #[tokio::test] + async fn relay_identity_refresh_keeps_last_good_key_after_fetch_error() { + let previous = Keys::generate().public_key().to_hex(); + let client = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".into(), + keys: Keys::generate(), + auth_tag_json: None, + }; + + let (refreshed, completed) = + refresh_relay_self(&client, Some(previous.clone()), "test").await; + assert_eq!(refreshed, Some(previous)); + assert!(!completed); + } + + #[test] + fn trusted_relay_workflow_uses_owner_for_explicit_target() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str()]], + &[owner.as_str(), agent.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + owner + ); + } + + #[test] + fn multiple_explicit_targets_each_use_owner() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent_a = Keys::generate().public_key().to_hex(); + let agent_b = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[ + &["buzz:workflow-mention", agent_a.as_str()], + &["buzz:workflow-mention", agent_b.as_str()], + ], + &[owner.as_str(), agent_a.as_str(), agent_b.as_str()], + ); + + for agent in [&agent_a, &agent_b] { + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), agent), + owner + ); + } + } + + #[test] + fn owner_as_explicit_target_uses_owner_without_duplicate_p_tag() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", owner.as_str()]], + &[owner.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &owner), + owner + ); + } + + #[test] + fn legacy_owner_p_tag_without_explicit_target_keeps_relay_signer() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = owner.clone(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[], + &[owner.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + + #[test] + fn p_tag_without_matching_explicit_target_keeps_relay_signer() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let other = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", other.as_str()]], + &[owner.as_str(), agent.as_str(), other.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + + #[test] + fn forged_or_tampered_workflow_keeps_raw_signer() { + let relay = Keys::generate(); + let attacker = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let mentions = [&["buzz:workflow-mention", agent.as_str()][..]]; + let forged = workflow_event( + &attacker, + Some(&owner), + &[&["buzz:workflow", "true"]], + &mentions, + &[agent.as_str()], + ); + assert_eq!( + effective_prompt_author(&forged, Some(&relay.public_key().to_hex()), &agent), + attacker.public_key().to_hex() + ); + + let mut tampered = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &mentions, + &[agent.as_str()], + ); + tampered.content = "tampered".into(); + assert_eq!( + effective_prompt_author(&tampered, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + + #[test] + fn malformed_or_ambiguous_metadata_fails_closed() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + let valid_mentions = [&["buzz:workflow-mention", agent.as_str()][..]]; + + for event in [ + workflow_event( + &relay, + Some(&owner), + &[], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + None, + &[&["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"], &["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true", "extra"]], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str(), "extra"]], + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[ + &["buzz:workflow-mention", agent.as_str()], + &["buzz:workflow-mention", agent.as_str()], + ], + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", "not-a-pubkey"]], + &[agent.as_str()], + ), + ] { + assert_eq!( + effective_prompt_author(&event, Some(&relay_hex), &agent), + relay_hex + ); + } + + let duplicate_owner = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ); + let mut tags: Vec = duplicate_owner.tags.iter().cloned().collect(); + tags.push(Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("duplicate owner")); + let duplicate_owner = + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags(tags) + .sign_with_keys(&relay) + .expect("signed event"); + assert_eq!( + effective_prompt_author(&duplicate_owner, Some(&relay_hex), &agent), + relay_hex + ); + } + + #[test] + fn wrong_kind_or_missing_relay_identity_fails_closed() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + let wrong_kind = EventBuilder::new(Kind::TextNote, "scheduled prompt") + .tags([ + Tag::parse(["buzz:workflow", "true"]).expect("marker"), + Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("owner"), + Tag::parse(["buzz:workflow-mention", agent.as_str()]).expect("workflow mention"), + ]) + .sign_with_keys(&relay) + .expect("signed event"); + assert_eq!( + effective_prompt_author(&wrong_kind, Some(&relay_hex), &agent), + relay_hex + ); + + let valid = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str()]], + &[agent.as_str()], + ); + assert_eq!(effective_prompt_author(&valid, None, &agent), relay_hex); + } +} + +#[cfg(test)] +mod author_gate_tests { + use super::*; + + /// A `RestClient` for tests. The author-gate decisions exercised here all + /// resolve from the owner pubkey or sibling cache before any HTTP call, so + /// this client is never actually used to make a request. + fn dummy_rest_client() -> relay::RestClient { + relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://localhost:0".into(), + keys: nostr::Keys::generate(), + auth_tag_json: None, + } + } + + const OWNER: &str = "00"; + const SIBLING: &str = "11"; + const EXTERNAL: &str = "22"; + const STRANGER: &str = "33"; + + /// Owner + a known sibling, none of them on the explicit allowlist. + fn cache_with_sibling() -> OwnerCache { + let cache = OwnerCache::new(Some(OWNER.into())); + cache.cache_sibling(SIBLING.into(), true); + cache.cache_sibling(STRANGER.into(), false); + cache.cache_sibling(EXTERNAL.into(), false); + cache + } + + /// Serve a NIP-11 document on a loopback port so `InboundAuthorGate` can be + /// built through the *same* constructor the listeners use, rather than by + /// injecting an already-resolved relay identity. This is what makes the + /// listener-to-gate wiring testable: a gate that never loads its identity + /// fails these tests instead of silently degrading to the raw signer. + pub(super) async fn nip11_server( + document: serde_json::Value, + ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { + nip11_scripted_server(std::collections::VecDeque::from([Ok(document)])).await + } + + /// Serve scripted NIP-11 responses. `Err(())` returns HTTP 500. + async fn nip11_scripted_server( + responses: std::collections::VecDeque>, + ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind NIP-11 test server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let responses = std::sync::Arc::new(tokio::sync::Mutex::new((responses, None))); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + let response = { + let mut scripted = responses.lock().await; + let response = if let Some(next) = scripted.0.pop_front() { + Some(next) + } else { + scripted.1.clone() + }; + if let Some(Ok(document)) = &response { + scripted.1 = Some(Ok(document.clone())); + } + response + }; + let Some(response) = response else { + continue; + }; + let Ok(document) = response else { + let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(response.as_bytes()).await; + continue; + }; + let body = document.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let rest = relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + (rest, server) + } + + /// Build a gate through the real `connect` path against a NIP-11 document + /// advertising `relay_hex` as the relay signer. Tests use this instead of + /// constructing `InboundAuthorGate` literally so that the identity load + /// stays part of what they cover. + async fn connected_gate( + relay_hex: &str, + agent: &str, + ) -> ( + InboundAuthorGate, + relay::RestClient, + tokio::task::JoinHandle<()>, + ) { + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + let gate = InboundAuthorGate::connect(&rest_client, agent, "test").await; + (gate, rest_client, server) + } + + /// A genuine relay-signed workflow dispatch that explicitly targets `agent` + /// on behalf of `owner` — the exact event shape a scheduled workflow emits. + pub(super) fn relay_signed_workflow_dispatch( + relay_keys: &nostr::Keys, + owner: &str, + agent: &str, + ) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", owner]).expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent]).expect("workflow mention tag"), + nostr::Tag::parse(["p", agent]).expect("recipient tag"), + ]) + .sign_with_keys(relay_keys) + .expect("signed workflow event") + } + + struct ListenerBoundaryScenario<'a> { + listener: ListenerBoundary, + relay_keys: &'a nostr::Keys, + workflow_owner: &'a str, + responses: std::collections::VecDeque>, + event_generation: u64, + channel_type: &'a str, + respond_to: RespondTo, + allowlist: HashSet, + cache_owner: bool, + cache_sibling: bool, + } + + async fn listener_boundary_scenario( + scenario: ListenerBoundaryScenario<'_>, + ) -> (Option, bool) { + let ListenerBoundaryScenario { + listener, + relay_keys, + workflow_owner, + responses, + event_generation, + channel_type, + respond_to, + allowlist, + cache_owner, + cache_sibling, + } = scenario; + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let (rest_client, server) = nip11_scripted_server(responses).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "listener startup").await; + let configured_owner = if cache_owner { + Some(workflow_owner.to_string()) + } else if cache_sibling { + Some(nostr::Keys::generate().public_key().to_hex()) + } else { + None + }; + let owner_cache = OwnerCache::new(configured_owner); + owner_cache.cache_sibling(relay_hex, false); + owner_cache.cache_sibling(workflow_owner.to_string(), cache_sibling); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: channel_type.into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let event = relay::BuzzEvent { + connection_generation: event_generation, + channel_id, + event: relay_signed_workflow_dispatch(relay_keys, workflow_owner, &agent), + }; + let authorized = match listener { + ListenerBoundary::Normal => { + authorize_normal_listener_event( + &mut gate, + event, + &respond_to, + &allowlist, + &owner_cache, + &channel_info, + &rest_client, + ) + .await + } + ListenerBoundary::Setup => { + setup_mode::authorize_setup_listener_event( + &mut gate, + event, + &respond_to, + &allowlist, + &owner_cache, + &channel_info, + &rest_client, + ) + .await + } + }; + let result = authorized.map(|event| event.into_parts().1); + server.abort(); + let allowed = result.is_some(); + (result, allowed) + } + + #[derive(Clone, Copy, Debug)] + enum ListenerBoundary { + Normal, + Setup, + } + + impl ListenerBoundary { + fn name(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Setup => "setup", + } + } + } + + /// Both production listener callables must attribute relay-signed workflow + /// events to the workflow owner and enforce policy there. A local + /// `allowed: true` replacement at either call site makes the Nobody case + /// fail; using the raw relay signer makes the OwnerOnly case fail. + #[tokio::test] + async fn production_listener_boundaries_apply_workflow_owner_policy() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let accepted_workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let accepted = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &accepted_workflow_owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::OwnerOnly, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + accepted.1, + "{} listener must allow the workflow owner", + listener.name() + ); + assert_eq!( + accepted.0.as_deref(), + Some(accepted_workflow_owner.as_str()), + "{} listener must preserve the effective workflow owner", + listener.name() + ); + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let denied_workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let denied = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &denied_workflow_owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::Nobody, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + !denied.1, + "{} listener must enforce respond-to=nobody", + listener.name() + ); + } + } + + /// Both production boundaries must retain DM classification when composing + /// trusted workflow attribution with configured author policy. External + /// allowlist entries and `Anyone` stay denied in a DM; owner and sibling + /// principals remain allowed; `Nobody` remains absolute. + #[tokio::test] + async fn production_listener_boundaries_enforce_dm_author_policy() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let external = nostr::Keys::generate().public_key().to_hex(); + let external_allowlist = HashSet::from([external.clone()]); + let denied_external = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &external, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Allowlist, + allowlist: external_allowlist, + cache_owner: false, + cache_sibling: false, + }) + .await; + assert!( + !denied_external.1, + "{} listener must deny an external allowlist entry in a DM", + listener.name() + ); + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let stranger = nostr::Keys::generate().public_key().to_hex(); + let denied_stranger = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &stranger, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Anyone, + allowlist: HashSet::new(), + cache_owner: false, + cache_sibling: false, + }) + .await; + assert!( + !denied_stranger.1, + "{} listener must deny a stranger in a DM under Anyone", + listener.name() + ); + + for (principal, cache_owner, cache_sibling, label) in [ + ( + nostr::Keys::generate().public_key().to_hex(), + true, + false, + "owner", + ), + ( + nostr::Keys::generate().public_key().to_hex(), + false, + true, + "sibling", + ), + ] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let allowed = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &principal, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Anyone, + allowlist: HashSet::new(), + cache_owner, + cache_sibling, + }) + .await; + assert!( + allowed.1, + "{} listener must allow the {label} in a DM", + listener.name() + ); + } + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let owner = nostr::Keys::generate().public_key().to_hex(); + let denied_nobody = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Nobody, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + !denied_nobody.1, + "{} listener must enforce Nobody in a DM", + listener.name() + ); + } + } + + /// Both production boundaries must perform the pending generation-zero + /// refresh before policy evaluation. Bypassing the gate invocation leaves + /// the relay signer denied and makes this recovery assertion fail. + #[tokio::test] + async fn production_listener_boundaries_recover_relay_identity() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let result = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &workflow_owner, + responses: std::collections::VecDeque::from([ + Err(()), + Err(()), + Ok(serde_json::json!({ "self": relay_hex })), + ]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::OwnerOnly, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + result.1, + "{} listener must recover identity before authorization", + listener.name() + ); + assert_eq!( + result.0.as_deref(), + Some(workflow_owner.as_str()), + "{} listener must preserve the recovered workflow owner", + listener.name() + ); + } + } + + /// The listener decision-boundary regression. + /// + /// Both listeners call `evaluate_listener_event`; it owns identity refresh, + /// channel trust, workflow attribution, and policy, with no production-visible + /// raw-policy helper alongside it. This test drives that exact callable + /// against a live NIP-11 document, so it fails if identity loading, + /// effective-author resolution, DM classification, or policy application + /// regresses. Replacing either listener call with the former raw-signer + /// `author_allowed` path is now a compile error because that policy is + /// private to the gate module. + #[tokio::test] + async fn test_connected_gate_wakes_owner_only_agent_for_relay_signed_workflow() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + assert!( + gate.has_relay_identity(), + "the gate must load the relay signing identity during construction" + ); + + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); + cache.cache_sibling(relay_hex.clone(), false); + + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let buzz_event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event, + }; + let decision = gate + .evaluate_listener_event( + &buzz_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &cache, + &channel_info, + &rest_client, + ) + .await; + + assert_eq!( + decision.effective_author, workflow_owner, + "a connected gate must attribute a relay-signed workflow dispatch to its owner, not the relay signer" + ); + assert!( + decision.allowed, + "an owner-only agent must wake for its own workflow's explicit mention" + ); + server.abort(); + } + + /// A gate whose relay identity is unavailable must fall back to the raw + /// signer and stay closed — the documented fail-closed behavior, and the + /// exact state the wiring regression above proves the listeners avoid. + #[tokio::test] + async fn test_gate_without_relay_identity_fails_closed_to_raw_signer() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + // A NIP-11 document with no `self` key: attribution is unavailable. + let (rest_client, server) = nip11_server(serde_json::json!({ "name": "relay" })).await; + + let gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + assert!( + !gate.has_relay_identity(), + "a NIP-11 document without `self` must leave attribution unavailable" + ); + + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(relay_hex.clone(), false); + + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + + assert_eq!( + decision.effective_author, relay_hex, + "without a verified relay identity the gate must fall back to the raw signer" + ); + assert!( + !decision.allowed, + "unattributed relay-signed output must not wake an owner-only agent" + ); + server.abort(); + } + + /// The first authorized event after reconnect must restore attribution + /// through the same decision boundary both listeners use, without a + /// separate identity-refresh call. + #[tokio::test] + async fn test_gate_refresh_arms_attribution_after_reconnect() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + + // Construct against an unreachable relay: no identity yet. + let unreachable = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:1".into(), + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + let mut gate = InboundAuthorGate::connect(&unreachable, &agent, "test").await; + assert!(!gate.has_relay_identity()); + + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); + cache.cache_sibling(relay_hex, false); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let buzz_event = relay::BuzzEvent { + connection_generation: 1, + channel_id, + event, + }; + + let decision = gate + .evaluate_listener_event( + &buzz_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!( + decision.effective_author, workflow_owner, + "a reconnect refresh must restore delegated workflow attribution" + ); + assert!(decision.allowed); + server.abort(); + } + + #[test] + fn refresh_needed_until_generation_completes() { + use super::inbound_author_gate::refresh_needed; + assert!(refresh_needed(None, 0)); + assert!(refresh_needed(None, 1)); + assert!(!refresh_needed(Some(0), 0)); + assert!(refresh_needed(Some(0), 1)); + assert!(!refresh_needed(Some(1), 1)); + assert!(!refresh_needed(Some(1), 0)); + assert!(refresh_needed(Some(1), 2)); + } + + #[tokio::test] + async fn test_generation_zero_retries_failed_startup_identity() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + // Both startup probes fail; HTTP then recovers without a WS reconnect. + let (rest_client, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Err(()), + Err(()), + Ok(serde_json::json!({ "self": relay_hex.clone() })), + ])) + .await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "startup").await; + assert!(!gate.has_relay_identity()); + let channel_id = Uuid::new_v4(); + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(relay_hex.clone(), false); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent), + }; + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + server.abort(); + assert!( + decision.allowed, + "a generation-0 workflow wake must recover after the startup NIP-11 failure" + ); + assert_eq!(decision.effective_author, workflow_owner); + } + + #[tokio::test] + async fn test_authoritative_startup_result_completes_generation_zero() { + let relay_keys = nostr::Keys::generate(); + let next_relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let next_relay_hex = next_relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(relay_hex.clone(), false); + owner_cache.cache_sibling(next_relay_hex.clone(), false); + for identity in [Some(relay_hex.clone()), None] { + let document = match &identity { + Some(key) => serde_json::json!({ "self": key }), + None => serde_json::json!({ "name": "relay without stable identity" }), + }; + let mut responses = std::collections::VecDeque::from([Ok(document.clone())]); + if identity.is_none() { + // A missing `self` probes /info as well as the root. + responses.push_back(Ok(document)); + } + responses.push_back(Ok(serde_json::json!({ "self": next_relay_hex.clone() }))); + let (rest_client, server) = nip11_scripted_server(responses).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "startup").await; + assert_eq!(gate.relay_identity_for_test(), identity.as_deref()); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let mut event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent), + }; + for _ in 0..2 { + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!(decision.allowed, identity.is_some()); + assert_eq!( + gate.relay_identity_for_test(), + identity.as_deref(), + "an authoritative startup response must not be fetched again at generation 0" + ); + } + event.connection_generation = 1; + event.event = relay_signed_workflow_dispatch(&next_relay_keys, &workflow_owner, &agent); + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert!(decision.allowed); + assert_eq!(decision.effective_author, workflow_owner); + assert_eq!( + gate.relay_identity_for_test(), + Some(next_relay_hex.as_str()), + "a later connection must still refresh after authoritative startup" + ); + server.abort(); + } + } + + #[tokio::test] + async fn test_generation_refresh_retries_after_nip11_failure() { + let old_relay = nostr::Keys::generate(); + let new_relay = nostr::Keys::generate(); + let old_relay_hex = old_relay.public_key().to_hex(); + let new_relay_hex = new_relay.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let channel_id = uuid::Uuid::new_v4(); + let (rest_client, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Ok(serde_json::json!({ "self": old_relay_hex.clone() })), + Err(()), + Err(()), + Ok(serde_json::json!({ "self": new_relay_hex.clone() })), + ])) + .await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(old_relay_hex.clone(), false); + owner_cache.cache_sibling(new_relay_hex.clone(), false); + let channel_info = pool::ChannelInfoResolver::new( + std::collections::HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "test".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); - assert_eq!(events.len(), 2); - assert!(events.iter().all(|event| event.pubkey == keys.public_key())); - assert!(events.iter().all(|event| event.verify().is_ok())); - } + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); - #[test] - fn project_owner_control_rejects_arbitrary_or_unaddressed_events() { - let keys = Keys::generate(); - let arbitrary = build_project_owner_announcement_events( - vec![ProjectOwnerAnnouncementTemplate { - kind: 1, - content: String::new(), - created_at: None, - tags: vec![vec!["d".to_string(), "project".to_string()]], - }], - &keys, + let new_event = relay::BuzzEvent { + connection_generation: 2, + channel_id, + event: relay_signed_workflow_dispatch(&new_relay, &workflow_owner, &agent), + }; + let first_new = gate + .evaluate_listener_event( + &new_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); + assert!( + !first_new.allowed, + "the new signer must remain fail-closed while NIP-11 is unavailable" ); - assert!(arbitrary.is_err()); - let unaddressed = build_project_owner_announcement_events( - vec![ProjectOwnerAnnouncementTemplate { - kind: 30_621, - content: String::new(), - created_at: None, - tags: vec![], - }], - &keys, + let recovered = gate + .evaluate_listener_event( + &new_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!(gate.relay_identity_for_test(), Some(new_relay_hex.as_str())); + assert_eq!(recovered.effective_author, workflow_owner); + assert!( + recovered.allowed, + "a later event on the same connection must use the refreshed relay key" ); - assert!(unaddressed.is_err()); - } -} -#[cfg(test)] -mod owner_cache_tests { - use super::*; + let stale_old_event = relay::BuzzEvent { + connection_generation: 2, + channel_id, + event: relay_signed_workflow_dispatch(&old_relay, &workflow_owner, &agent), + }; + let stale = gate + .evaluate_listener_event( + &stale_old_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert!(!stale.allowed, "the rotated-away relay key must be evicted"); - #[test] - fn new_with_some_caches_immediately() { - let cache = OwnerCache::new(Some("abcd".into())); - assert_eq!(cache.get(), Some("abcd")); + server.abort(); } - #[test] - fn new_with_none_returns_none() { - let cache = OwnerCache::new(None); - assert!(cache.get().is_none()); - } + #[tokio::test] + async fn test_combined_gate_accepts_explicit_trusted_workflow_target_only() { + let relay = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent.as_str()]) + .expect("workflow mention tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("recipient tag"), + ]) + .sign_with_keys(&relay) + .expect("signed workflow event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); - #[test] - fn get_returns_cached_value() { - let cache = OwnerCache::new(Some("ab".repeat(32))); - assert_eq!(cache.get(), Some("ab".repeat(32)).as_deref()); + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + assert_eq!(decision.effective_author, workflow_owner); + assert!( + decision.allowed, + "a verified workflow owner for an explicitly targeted agent must flow through the existing sibling policy" + ); + server.abort(); } -} -#[cfg(test)] -mod author_gate_tests { - use super::*; - - /// A `RestClient` for tests. The author-gate decisions exercised here all - /// resolve from the owner pubkey or sibling cache before any HTTP call, so - /// this client is never actually used to make a request. - fn dummy_rest_client() -> relay::RestClient { - relay::RestClient { - http: reqwest::Client::new(), - base_url: "http://localhost:0".into(), - keys: nostr::Keys::generate(), - auth_tag_json: None, - } + #[tokio::test] + async fn test_combined_gate_rejects_owner_p_tag_without_explicit_workflow_target() { + let relay = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = workflow_owner.clone(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("legacy owner p tag"), + ]) + .sign_with_keys(&relay) + .expect("signed workflow event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(relay.public_key().to_hex(), false); + + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + server.abort(); + assert_eq!(decision.effective_author, relay.public_key().to_hex()); + assert!( + !decision.allowed, + "the legacy owner p tag alone must not wake an agent-owned workflow" + ); } - const OWNER: &str = "00"; - const SIBLING: &str = "11"; - const EXTERNAL: &str = "22"; - const STRANGER: &str = "33"; - - /// Owner + a known sibling, none of them on the explicit allowlist. - fn cache_with_sibling() -> OwnerCache { - let cache = OwnerCache::new(Some(OWNER.into())); - cache.cache_sibling(SIBLING.into(), true); - cache.cache_sibling(STRANGER.into(), false); - cache.cache_sibling(EXTERNAL.into(), false); - cache + #[tokio::test] + async fn test_combined_gate_rejects_forged_workflow_attribution() { + let relay = nostr::Keys::generate(); + let attacker = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent.as_str()]) + .expect("workflow mention tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("recipient tag"), + ]) + .sign_with_keys(&attacker) + .expect("signed forged event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(attacker.public_key().to_hex(), false); + + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + server.abort(); + assert_eq!(decision.effective_author, attacker.public_key().to_hex()); + assert!( + !decision.allowed, + "an attacker-signed workflow event must not borrow trusted owner authority" + ); } #[tokio::test] @@ -5572,7 +7678,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, SIBLING, @@ -5590,7 +7696,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -5608,7 +7714,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, STRANGER, @@ -5626,7 +7732,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::new(); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, OWNER, @@ -5647,7 +7753,7 @@ mod author_gate_tests { async fn test_owner_only_rejects_stranger_so_no_steer() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::OwnerOnly, &HashSet::new(), STRANGER, @@ -5665,7 +7771,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::OwnerOnly, &HashSet::new(), who, @@ -5851,7 +7957,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -5868,7 +7974,7 @@ mod author_gate_tests { async fn test_dm_rejects_stranger_under_anyone() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Anyone, &HashSet::new(), STRANGER, @@ -5891,7 +7997,7 @@ mod author_gate_tests { ] { for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &mode, &HashSet::new(), who, @@ -5910,7 +8016,7 @@ mod author_gate_tests { async fn test_dm_nobody_rejects_even_owner() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Nobody, &HashSet::new(), OWNER, @@ -6050,7 +8156,7 @@ mod author_gate_tests { let is_dm = is_dm_channel(id, &channel_info).await; assert!(is_dm, "unknown startup metadata must fail closed as DM"); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -7138,6 +9244,7 @@ mod build_mcp_servers_tests { initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, + session_policy: scope::SessionPolicy::Channel, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -7162,6 +9269,7 @@ mod build_mcp_servers_tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -7362,6 +9470,7 @@ mod error_outcome_emission_tests { initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, + session_policy: scope::SessionPolicy::Channel, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -7386,6 +9495,7 @@ mod error_outcome_emission_tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -7437,14 +9547,14 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let steer_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "live-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "live-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7453,6 +9563,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7479,7 +9590,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7500,23 +9611,25 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn in_flight_stale_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "replacement-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7525,6 +9638,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7551,7 +9665,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7572,9 +9686,11 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7582,50 +9698,54 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let steer_event_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "live-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "live-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &scope::SessionScope::Conversation { channel_id }, steer_event_id.into(), "live-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("idle returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn late_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "replacement-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(!pool.record_successful_steer( - channel_id, + &scope::SessionScope::Conversation { channel_id }, "stale-event".into(), "old-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("replacement agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7640,6 +9760,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7665,7 +9786,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7686,7 +9807,10 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(!returned.state.deliveries.contains_key(&channel_id)); + assert!(!returned + .state + .deliveries + .contains_key(&scope::SessionScope::Conversation { channel_id })); } /// Drive one error outcome through `handle_prompt_result` and return how @@ -7705,6 +9829,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7728,7 +9853,9 @@ mod error_outcome_emission_tests { let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7782,6 +9909,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7833,6 +9961,103 @@ mod error_outcome_emission_tests { assert_eq!(panic.turn_id.as_deref(), Some("panic-turn-id")); } + // Fix #3: a panicked thread-scoped task must clear its EXACT scope from the + // in-flight set (via meta.scope), not `Conversation(channel_id)`. Otherwise + // the requeued batch stays wedged until the ~2h in-flight backstop. + #[tokio::test] + async fn panic_recovery_frees_the_exact_thread_scope() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let scope = scope::SessionScope::Thread { + channel_id, + root_event_id: "a".repeat(64), + }; + + // A thread-scoped batch is in flight (queue marks the Thread scope). + let mut queue = EventQueue::new(config::DedupMode::Queue); + let event = EventBuilder::new(Kind::Custom(9), "x") + .tags([]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + queue.push(queue::QueuedEvent { + channel_id, + scope: scope.clone(), + event, + received_at: std::time::Instant::now(), + prompt_tag: "t".into(), + }); + let batch = queue.flush_next().expect("flush thread batch"); + assert!(queue.is_scope_in_flight(&scope)); + + // Spawn a task we can panic/abort, wired to the same scope + a + // recoverable batch so recovery requeues it. + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async move { + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }); + pool.task_map_mut().insert( + abort_handle.id(), + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + scope: Some(scope.clone()), + turn_id: "panic-turn-id".to_string(), + recoverable_batch: Some(batch), + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + started_rx.await.unwrap(); + abort_handle.abort(); + let join_error = pool.join_set.join_next().await.unwrap().unwrap_err(); + + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut typing_channels = HashMap::new(); + // Pre-open the circuit so recovery returns before attempting a real + // respawn subprocess (mark_complete runs before the circuit check). + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: Some(std::time::Instant::now() + Duration::from_secs(3600)), + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + + recover_panicked_agent( + &mut pool, + &mut queue, + &config, + join_error, + &mut heartbeat_in_flight, + &removed_channels, + &mut typing_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + ); + + // The exact Thread scope is freed and the requeued batch is flushable + // again immediately — not stranded behind a Conversation(channel_id) + // entry until the backstop deadline. + assert!( + !queue.is_scope_in_flight(&scope), + "panic recovery must clear the exact Thread scope" + ); + // The requeued batch is queued again (recovery uses `requeue`, which + // applies a short retry backoff — so it is undispatched work now and + // becomes flushable once the backoff expires, rather than being stranded + // in-flight behind the wrong scope until the ~2h backstop). + assert!( + queue.has_undispatched_work(), + "requeued thread batch must be queued (undispatched) after recovery" + ); + } + #[tokio::test] async fn idle_timeout_emits_exactly_one_feed_event() { assert_eq!( @@ -7875,6 +10100,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7896,7 +10122,9 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7944,8 +10172,10 @@ mod error_outcome_emission_tests { let event = EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&keys) .unwrap(); + let __cid = Uuid::new_v4(); FlushBatch { - channel_id: Uuid::new_v4(), + channel_id: __cid, + scope: scope::SessionScope::Conversation { channel_id: __cid }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7967,6 +10197,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7987,7 +10218,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -8007,7 +10238,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -8053,6 +10284,7 @@ mod error_outcome_emission_tests { .unwrap(); FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8073,6 +10305,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8093,7 +10326,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -8113,7 +10346,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -8150,6 +10383,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8171,6 +10405,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&Keys::generate()) @@ -8183,7 +10418,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -8245,6 +10480,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8265,6 +10501,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "final-attempt") .sign_with_keys(&Keys::generate()) @@ -8277,7 +10514,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -8311,7 +10548,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 0, "batch with an exhausted retry budget must be dead-lettered, not requeued" ); @@ -8345,6 +10582,7 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: original_event.clone(), prompt_tag: "test".into(), @@ -8362,6 +10600,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8376,6 +10615,7 @@ mod error_outcome_emission_tests { // handle_prompt_result runs. queue.push(QueuedEvent { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, event: new_event.clone(), received_at: std::time::Instant::now(), prompt_tag: "test".into(), @@ -8394,7 +10634,7 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), batch: Some(batch), @@ -8502,6 +10742,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8524,7 +10765,9 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), // Explicit Stop already dropped the batch upstream in @@ -8604,11 +10847,13 @@ mod error_outcome_emission_tests { #[tokio::test] async fn indeterminate_project_context_requeues_without_poisoning_agent_or_circuit() { let channel_id = Uuid::new_v4(); + let session_scope = scope::SessionScope::Conversation { channel_id }; let event = EventBuilder::new(Kind::Custom(9), "project work") .sign_with_keys(&Keys::generate()) .unwrap(); let batch = FlushBatch { channel_id, + scope: session_scope.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8622,7 +10867,7 @@ mod error_outcome_emission_tests { agent .state .sessions - .insert(channel_id, "healthy-session".into()); + .insert(session_scope.clone(), "healthy-session".into()); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); pool.task_map_mut().insert( @@ -8630,6 +10875,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(session_scope.clone()), turn_id: "indeterminate-project".into(), recoverable_batch: None, control_tx: None, @@ -8650,7 +10896,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(session_scope.clone()), turn_id: "indeterminate-project".into(), outcome: PromptOutcome::ProjectContextIndeterminate( "project context is indeterminate".into(), @@ -8679,10 +10925,14 @@ mod error_outcome_emission_tests { .as_ref() .expect("healthy agent returns to its slot"); assert_eq!( - returned.state.sessions.get(&channel_id).map(String::as_str), + returned + .state + .sessions + .get(&session_scope) + .map(String::as_str), Some("healthy-session") ); - assert_eq!(queue.queued_event_count(&channel_id), 1); + assert_eq!(queue.queued_event_count(channel_id), 1); assert!(crash_history[0].crash_times.is_empty()); assert!(crash_history[0].open_until.is_none()); assert!(!crash_history[0].respawn_in_flight); @@ -8756,6 +11006,7 @@ mod error_outcome_emission_tests { let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8779,6 +11030,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8799,7 +11051,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(auth_error), batch: Some(batch), @@ -8825,7 +11077,7 @@ mod error_outcome_emission_tests { "auth error must dead-letter immediately — batch must not be requeued" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 0, "auth error must dead-letter immediately — no events should be pending" ); @@ -8842,6 +11094,7 @@ mod error_outcome_emission_tests { let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8865,6 +11118,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8885,7 +11139,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(usage_error), batch: Some(batch), @@ -8911,7 +11165,7 @@ mod error_outcome_emission_tests { "non-auth application error must requeue the batch for retry" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 1, "non-auth application error must preserve the event for retry" ); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 29f4c814b0b..f549a2ffa52 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -34,7 +34,7 @@ use crate::acp::{ model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, StopReason, SystemPromptTransport, }; -use crate::config::{compose_session_title, DedupMode, PermissionMode}; +use crate::config::{compose_scoped_session_title, DedupMode, PermissionMode}; use crate::observer; use crate::prompt_project::{pick_authoritative_project_home, PromptProjectInfo}; use crate::queue::{ @@ -42,6 +42,7 @@ use crate::queue::{ PromptProfile, PromptProfileLookup, ThreadTags, }; use crate::relay::{ChannelInfo, RestClient}; +use crate::scope::SessionScope; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). @@ -60,6 +61,10 @@ pub struct SuccessfulSteerDelivery { pub struct TaskMeta { pub agent_index: usize, pub channel_id: Option, + /// Session scope of the in-flight turn (mid-turn steer/signal routing and + /// scope-to-worker affinity target this). `None` for heartbeat tasks. + /// Invariant when `Some`: `scope.channel_id() == channel_id.unwrap()`. + pub scope: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -113,37 +118,37 @@ pub struct ChannelDeliveryState { /// spawning a real agent subprocess. #[derive(Default)] pub struct SessionState { - /// channel_id → session_id - pub sessions: HashMap, + /// session scope → session_id + pub sessions: HashMap, pub heartbeat_session: Option, - /// Per-channel turn counters for proactive session rotation. + /// Per-scope turn counters for proactive session rotation. /// Incremented on each successful prompt; reset when the session is rotated. - pub turn_counts: HashMap, + pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, /// Whether the live heartbeat session has successfully received ``. pub heartbeat_standing_context_sent: bool, - /// channel_id → rendered NIP-AE core prompt section, populated once at + /// session scope → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). - pub core_sections: HashMap, - /// channel_id → rendered `` metadata section. + pub core_sections: HashMap, + /// session scope → rendered `` metadata section. /// /// Populated once before session creation (same lifecycle as `core_sections`). /// Absent when the channel has no canvas, the canvas content is blank, or the /// fetch fails — all fail open. Cleared on session invalidation alongside /// `core_sections` so the next session picks up any canvas change. - pub canvas_sections: HashMap, - /// Per-channel successful-delivery state. Created with the ACP session and + pub canvas_sections: HashMap, + /// Per-scope successful-delivery state. Created with the ACP session and /// cleared atomically with every invalidation path. - pub deliveries: HashMap, + pub deliveries: HashMap, } impl SessionState { /// Invalidate the session (and turn counter) for a specific prompt source. pub fn invalidate(&mut self, source: &PromptSource) { match source { - PromptSource::Channel(cid) => { - self.invalidate_channel(cid); + PromptSource::Channel(scope) => { + self.invalidate_scope(scope); } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -153,14 +158,39 @@ impl SessionState { } } - /// Invalidate a single channel's session and turn counter. - /// Returns `true` if the channel had an active session. - pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool { - self.turn_counts.remove(channel_id); - self.core_sections.remove(channel_id); - self.canvas_sections.remove(channel_id); - self.deliveries.remove(channel_id); - self.sessions.remove(channel_id).is_some() + /// Invalidate a single session scope's session and turn counter. + /// Returns `true` if the scope had an active session. + pub fn invalidate_scope(&mut self, scope: &SessionScope) -> bool { + self.turn_counts.remove(scope); + self.core_sections.remove(scope); + self.canvas_sections.remove(scope); + self.deliveries.remove(scope); + self.sessions.remove(scope).is_some() + } + + /// Invalidate every session scope belonging to `channel_id` (channel-wide + /// cleanup, e.g. when the agent is removed from a channel). Returns the + /// number of scopes that had an active session. + pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> usize { + let scopes: Vec = self + .sessions + .keys() + .chain(self.turn_counts.keys()) + .chain(self.core_sections.keys()) + .chain(self.canvas_sections.keys()) + .chain(self.deliveries.keys()) + .filter(|s| s.channel_id() == *channel_id) + .cloned() + .collect::>() + .into_iter() + .collect(); + let mut count = 0; + for scope in scopes { + if self.invalidate_scope(&scope) { + count += 1; + } + } + count } /// Invalidate all sessions and turn counters (e.g. after agent exit). @@ -175,24 +205,25 @@ impl SessionState { self.deliveries.clear(); } - pub(crate) fn mark_channel_delivery_success( + pub(crate) fn mark_scope_delivery_success( &mut self, - channel_id: Uuid, + scope: SessionScope, standing_context_sent: bool, event_ids: impl IntoIterator, ) { - let delivery = self.deliveries.entry(channel_id).or_default(); + let delivery = self.deliveries.entry(scope).or_default(); delivery.standing_context_sent |= standing_context_sent; delivery.delivered_event_ids.extend(event_ids); } #[cfg(test)] fn has_channel_state(&self, channel_id: &Uuid) -> bool { - self.sessions.contains_key(channel_id) - || self.turn_counts.contains_key(channel_id) - || self.core_sections.contains_key(channel_id) - || self.canvas_sections.contains_key(channel_id) - || self.deliveries.contains_key(channel_id) + let matches = |s: &SessionScope| s.channel_id() == *channel_id; + self.sessions.keys().any(matches) + || self.turn_counts.keys().any(matches) + || self.core_sections.keys().any(matches) + || self.canvas_sections.keys().any(matches) + || self.deliveries.keys().any(matches) } } @@ -299,6 +330,13 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver, pub join_set: JoinSet<()>, task_map: HashMap, + /// Authoritative directory of which worker most recently owned each session + /// scope's provider session. Survives while a worker is checked out (its + /// `SessionState` is invisible to the pool then), so a busy owner does not + /// cause another worker to open a duplicate session for the same thread. + /// Best-effort: stale entries (rotation, crash/respawn) self-heal on the + /// next dispatch and are pruned on channel-wide session invalidation. + session_owners: HashMap, } /// Result returned by a completed prompt task. @@ -313,12 +351,40 @@ pub struct PromptResult { } /// Whether the prompt came from a channel event or a heartbeat. +/// +/// The channel variant carries the full [`SessionScope`] resolved at admission +/// (conversation or thread), not just the channel id, so completion and +/// invalidation target the exact session. Use [`channel_id`](PromptSource::channel_id) +/// where only the channel is needed. #[derive(Debug)] pub enum PromptSource { - Channel(Uuid), + Channel(SessionScope), Heartbeat, } +impl PromptSource { + /// The channel this prompt belongs to, or `None` for heartbeats. + pub fn channel_id(&self) -> Option { + match self { + Self::Channel(scope) => Some(scope.channel_id()), + Self::Heartbeat => None, + } + } + + /// The exact session scope this prompt belongs to, or `None` for + /// heartbeats. Callers that must target the precise thread (e.g. clearing a + /// typing indicator on completion) use this rather than [`channel_id`], so a + /// finishing turn never disturbs a sibling thread in the same channel. + /// + /// [`channel_id`]: PromptSource::channel_id + pub fn scope(&self) -> Option<&SessionScope> { + match self { + Self::Channel(scope) => Some(scope), + Self::Heartbeat => None, + } + } +} + /// Apply state effects for Race 1, where a control signal arrives just after the /// prompt completed naturally. The prompt result has already been consumed by /// `select!`, so the harness must synthesize a successful result while still @@ -700,18 +766,16 @@ pub struct PromptContext { pub turn_liveness_interval: Duration, pub dedup_mode: DedupMode, pub system_prompt: Option, - /// Sanitized title for each new ACP session, sent as `_meta.sessionTitle` - /// on `session/new`. Never part of the prompt. + /// Sanitized agent name used to compose `_meta.sessionTitle` on session/new. + /// Channel sessions add the channel name; thread sessions also add the root + /// ID prefix. Never part of the prompt. pub session_title: Option, pub team_instructions: Option, pub heartbeat_prompt: Option, - /// Base prompt content, or `None` if `--no-base-prompt` was passed. - /// - /// `'static` because `PromptContext` is `Arc`-shared across async tasks. - /// Content from `--base-prompt-file` is promoted via `Box::leak` in `main.rs` - /// after validated file read in `Config::from_cli()`. The compiled-in default - /// (`include_str!`) is inherently `'static`. - pub base_prompt: Option<&'static str>, + /// Base instructions with the configured policy's Session Model appended, + /// assembled once and shared by modern and legacy ACP standing context. + /// `None` when `--no-base-prompt` was passed. + pub base_prompt: Option, pub cwd: String, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, @@ -759,21 +823,50 @@ impl AgentPool { result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + session_owners: HashMap::new(), + } + } + + /// Record which worker is handling `scope` so a later dispatch can detect a + /// busy owner and avoid opening a duplicate session on another worker. + pub fn record_scope_owner(&mut self, scope: SessionScope, agent_index: usize) { + self.session_owners.insert(scope, agent_index); + } + + /// True when this scope should be **held** (left queued) rather than + /// dispatched to a fresh worker, because the worker that owns its provider + /// session is currently checked out (busy on another turn). + /// + /// Only holds when no idle worker already holds the session + /// ([`has_session_for`](Self::has_session_for) is false): if an idle owner + /// exists, [`try_claim`](Self::try_claim) reuses it directly. Holding waits + /// for the busy owner to return so its exact session (and tool/turn + /// context) is reused, instead of forking a second session for the thread. + pub fn should_hold_for_busy_owner(&self, scope: &SessionScope) -> bool { + if self.has_session_for(scope) { + return false; + } + match self.session_owners.get(scope) { + Some(&owner_idx) => self.task_map.values().any(|m| m.agent_index == owner_idx), + None => false, } } - /// Try to claim an idle agent for the given channel (or heartbeat if `None`). + /// Try to claim an idle agent for the given session scope (or heartbeat if + /// `None`). /// - /// Pass 1: prefer an agent that already has a session for `channel_id`. + /// Pass 1: prefer an agent that already has a session for this exact scope + /// (thread affinity — repeated activity in a thread reuses that thread's + /// provider session). /// Pass 2: any idle agent. /// /// Returns `None` if all agents are checked out. - pub fn try_claim(&mut self, channel_id: Option) -> Option { - // Pass 1: prefer agent with existing session for this channel. - if let Some(cid) = channel_id { + pub fn try_claim(&mut self, scope: Option<&SessionScope>) -> Option { + // Pass 1: prefer agent with existing session for this scope. + if let Some(scope) = scope { let idx = self.agents.iter().position(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&cid)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }); if let Some(i) = idx { @@ -807,12 +900,12 @@ impl AgentPool { self.agents.iter().any(|slot| slot.is_some()) } - /// Whether any idle agent already has a session for `channel_id`. + /// Whether any idle agent already has a session for `scope`. /// Used to compute `affinity_hit` before calling `try_claim`. - pub fn has_session_for(&self, channel_id: Uuid) -> bool { + pub fn has_session_for(&self, scope: &SessionScope) -> bool { self.agents.iter().any(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&channel_id)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }) } @@ -858,13 +951,13 @@ impl AgentPool { /// event and let normal dispatch handle delivery. pub fn send_steer( &mut self, - channel_id: Uuid, + scope: &SessionScope, request: SteerRequest, ) -> Result<(), SteerError> { let meta = self .task_map .values_mut() - .find(|m| m.channel_id == Some(channel_id)) + .find(|m| m.scope.as_ref() == Some(scope)) .ok_or(SteerError::PromptCompleted)?; let tx = meta .steer_tx @@ -880,14 +973,14 @@ impl AgentPool { /// we write directly to the idle agent's matching live-session ledger. pub fn record_successful_steer( &mut self, - channel_id: Uuid, + scope: &SessionScope, event_id: String, session_id: String, ) -> bool { if let Some(meta) = self .task_map .values_mut() - .find(|meta| meta.channel_id == Some(channel_id)) + .find(|meta| meta.scope.as_ref() == Some(scope)) { meta.successful_steer_deliveries .insert(SuccessfulSteerDelivery { @@ -898,13 +991,13 @@ impl AgentPool { } let Some(agent) = self.agents.iter_mut().flatten().find(|agent| { - agent.state.sessions.get(&channel_id).map(String::as_str) == Some(session_id.as_str()) + agent.state.sessions.get(scope).map(String::as_str) == Some(session_id.as_str()) }) else { return false; }; agent .state - .mark_channel_delivery_success(channel_id, false, [event_id]); + .mark_scope_delivery_success(scope.clone(), false, [event_id]); true } @@ -955,17 +1048,65 @@ impl AgentPool { let mut count = 0; for slot in &mut self.agents { if let Some(agent) = slot.as_mut() { - if agent.state.invalidate_channel(&channel_id) { + // Channel-wide: clears every child thread scope for the channel. + count += agent.state.invalidate_channel(&channel_id); + } + } + // Drop every scope-owner entry for this channel so the directory does + // not grow without bound and cannot strand a held batch behind a stale + // owner after the channel's sessions are gone. + self.session_owners + .retain(|scope, _| scope.channel_id() != channel_id); + count + } + + /// Invalidate the session for one exact scope across every worker, and drop + /// its scope-owner entry. The scope-precise counterpart of + /// [`invalidate_channel_sessions`](Self::invalidate_channel_sessions): under + /// thread policy an idle `!rotate` in thread A must rotate only thread A's + /// session, leaving sibling threads in the same channel untouched. Under the + /// default channel policy the scope is `Conversation(channel_id)` — the sole + /// scope for the channel — so this matches the channel-wide behavior. + /// Returns the number of workers that held a session for the scope. + pub fn invalidate_scope_session(&mut self, scope: &SessionScope) -> usize { + let mut count = 0; + for slot in &mut self.agents { + if let Some(agent) = slot.as_mut() { + if agent.state.invalidate_scope(scope) { count += 1; } } } + self.session_owners.remove(scope); count } + /// Whether a channel-only control could name more than one session scope. + /// + /// Include idle and checked-out sessions, not just active turns: selecting + /// the first worker for an idle model switch is equally ambiguous. Stale + /// ownership entries may conservatively reject a control until reconciled. + pub fn channel_control_is_ambiguous(&self, channel_id: Uuid) -> bool { + let mut scopes = self + .session_owners + .keys() + .chain( + self.agents + .iter() + .flatten() + .flat_map(|a| a.state.sessions.keys()), + ) + .chain(self.task_map.values().filter_map(|m| m.scope.as_ref())) + .filter(|scope| scope.channel_id() == channel_id); + let Some(first) = scopes.next() else { + return false; + }; + scopes.any(|scope| scope != first) + } + /// Idle-path model switch: set `desired_model` on the idle agent for - /// `channel_id` and invalidate its session so the next turn re-creates the - /// session under the new model. + /// `channel_id` and invalidate its exact session scope so the next turn + /// re-creates that session under the new model. /// /// Pre-cancel guard: the desired model is validated against the agent's /// cached catalog *before* the session is invalidated, so an unsupported @@ -982,14 +1123,27 @@ impl AgentPool { model_id: &str, request_id: Option, ) -> IdleSwitchResult { - let Some(agent) = self - .agents - .iter_mut() - .flatten() - .find(|a| a.state.sessions.contains_key(&channel_id)) + if self.channel_control_is_ambiguous(channel_id) { + return IdleSwitchResult::AmbiguousTarget; + } + let Some((agent_index, scope)) = + self.agents.iter().enumerate().find_map(|(index, slot)| { + slot.as_ref().and_then(|agent| { + agent + .state + .sessions + .keys() + .find(|scope| scope.channel_id() == channel_id) + .cloned() + .map(|scope| (index, scope)) + }) + }) else { return IdleSwitchResult::NoIdleAgent; }; + let Some(agent) = self.agents.get_mut(agent_index).and_then(Option::as_mut) else { + return IdleSwitchResult::NoIdleAgent; + }; // Pre-cancel guard against the cached catalog. None = catalog not yet // populated (no session ever created); defer validation to apply time. @@ -1008,7 +1162,8 @@ impl AgentPool { // Carry the pick's correlator so a deferred-validation miss on the next // turn's session creation emits a late frame the Desktop can match. agent.desired_model_request_id = request_id; - agent.state.invalidate_channel(&channel_id); + agent.state.invalidate_scope(&scope); + self.session_owners.remove(&scope); IdleSwitchResult::Switched } } @@ -1016,7 +1171,9 @@ impl AgentPool { /// Outcome of [`AgentPool::switch_idle_agent_model`]. #[derive(Debug, PartialEq, Eq)] pub enum IdleSwitchResult { - /// `desired_model` set and the channel session invalidated. + /// More than one session scope belongs to this channel; nothing changed. + AmbiguousTarget, + /// `desired_model` set and the selected session invalidated. Switched, /// Desired model is not in the agent's cached catalog — pick rejected, /// session untouched. @@ -1100,7 +1257,7 @@ struct NewSessionChannelContext<'a> { huddle_instructions: Option<&'a str>, canvas: Option<&'a str>, name: Option<&'a str>, - id: Option, + scope: Option<&'a SessionScope>, channel_type: Option<&'a str>, } @@ -1121,7 +1278,11 @@ async fn create_session_and_apply_model( with_huddle_instructions( with_core( with_team( - framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + framed_system_prompt( + &ctx.cwd, + ctx.base_prompt.as_deref(), + ctx.system_prompt.as_deref(), + ), ctx.team_instructions.as_deref(), ), agent_core, @@ -1131,13 +1292,16 @@ async fn create_session_and_apply_model( channel.canvas, ); - let session_title = ctx - .session_title - .as_deref() - .map(|agent_name| compose_session_title(agent_name, channel.name)); + let session_title = ctx.session_title.as_deref().map(|agent_name| { + compose_scoped_session_title( + agent_name, + channel.name, + channel.scope.and_then(SessionScope::root_event_id), + ) + }); let mcp_servers = mcp_servers_with_git_origin( &ctx.mcp_servers, - channel.id, + channel.scope.map(SessionScope::channel_id), channel.channel_type, ctx.session_title.as_deref(), ); @@ -1877,13 +2041,10 @@ pub async fn run_prompt_task( ) { // Is this a channel prompt or a heartbeat? let source = match &batch { - Some(b) => PromptSource::Channel(b.channel_id), + Some(b) => PromptSource::Channel(b.scope.clone()), None => PromptSource::Heartbeat, }; - let observer_channel_id = match &source { - PromptSource::Channel(channel_id) => Some(*channel_id), - PromptSource::Heartbeat => None, - }; + let observer_channel_id = source.channel_id(); let turn_started_at = chrono::Utc::now().to_rfc3339(); agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, @@ -1959,11 +2120,11 @@ pub async fn run_prompt_task( // outcome: fail closed and preserve the batch without poisoning the healthy // ACP process. let resolved_channel_info = match &source { - PromptSource::Channel(channel_id) => match ctx.channel_info.resolve(*channel_id).await { + PromptSource::Channel(scope) => match ctx.channel_info.resolve(scope.channel_id()).await { Ok(info) => info, Err(error) => { tracing::warn!( - channel_id = %channel_id, + channel_id = %scope.channel_id(), "project context is indeterminate; requeueing turn before ACP session creation: {}", error.0 ); @@ -2007,11 +2168,15 @@ pub async fn run_prompt_task( // // Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch. if ctx.memory_enabled { - if let (PromptSource::Channel(cid), Some(owner_pk)) = + if let (PromptSource::Channel(scope), Some(owner_pk)) = (&source, ctx.agent_owner_pubkey.as_ref()) { - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - if is_new_channel_session && !agent.state.core_sections.contains_key(cid) { + // Session state is keyed by scope: repeated activity in a thread + // reuses exactly that thread's session. `cid` is only for + // channel-level fetches/logging. + let cid = &scope.channel_id(); + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + if is_new_channel_session && !agent.state.core_sections.contains_key(scope) { // Bounded — we'd rather start the session with no core hint // than block session creation on a stalled relay. const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); @@ -2036,10 +2201,11 @@ pub async fn run_prompt_task( tracing::info!( target: "engram::core", channel = %cid, + scope = %scope.telemetry_label(), section_len = rendered.len(), "injected NIP-AE core section into system prompt" ); - agent.state.core_sections.insert(*cid, rendered); + agent.state.core_sections.insert(scope.clone(), rendered); } } } @@ -2057,29 +2223,30 @@ pub async fn run_prompt_task( // commit it to `canvas_sections` only after session creation succeeds. This // prevents a stale revision A surviving a failed create and being re-used by // the next attempt after the canvas was cleared. - let mut pending_canvas: Option<(Uuid, String)> = None; + let mut pending_canvas: Option<(SessionScope, String)> = None; let mut huddle_instructions: Option = None; // 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); + if let PromptSource::Channel(scope) = &source { + let cid = scope.channel_id(); + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + let needs_canvas = + is_new_channel_session && !agent.state.canvas_sections.contains_key(scope); if is_new_channel_session { let (is_dm, resolved_channel, resolved_channel_type) = resolve_new_session_channel_context(resolved_channel_info.as_ref()).await; title_channel = resolved_channel; origin_channel_type = resolved_channel_type; if let Some(owner) = ctx.agent_owner_pubkey.as_ref() { - huddle_instructions = - fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await; + huddle_instructions = fetch_huddle_instructions(cid, owner, &ctx.rest_client).await; } // 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 { - if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await { - pending_canvas = Some((*cid, section)); + if let Some(section) = fetch_canvas_section(cid, &ctx.rest_client).await { + pending_canvas = Some((scope.clone(), section)); } } } @@ -2088,31 +2255,31 @@ pub async fn run_prompt_task( // The core section to fold into the system prompt for this turn's session. // Channel-scoped; heartbeats carry no owner core. let agent_core: Option = match &source { - PromptSource::Channel(cid) => agent.state.core_sections.get(cid).cloned(), + PromptSource::Channel(scope) => agent.state.core_sections.get(scope).cloned(), PromptSource::Heartbeat => None, }; // The canvas metadata section — channel-scoped, absent for heartbeats/DMs. // Prefer the committed cache; fall back to pending (for new sessions being created now). let agent_canvas: Option = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Channel(scope) => agent .state .canvas_sections - .get(cid) + .get(scope) .cloned() .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())), PromptSource::Heartbeat => None, }; let (session_id, is_new_session) = match &source { - PromptSource::Channel(cid) => { - if let Some(sid) = agent.state.sessions.get(cid) { + PromptSource::Channel(scope) => { + let cid = &scope.channel_id(); + if let Some(sid) = agent.state.sessions.get(scope) { (sid.clone(), false) } else { - // The title is channel-qualified (`Agent · #channel`) so one - // agent in several channels doesn't produce identical session - // rows; `title_channel` comes from the single resolve above and - // is `None` for DM, unresolved, and unnamed channels. + // The title includes channel and, for thread sessions, the + // canonical root prefix so sibling sessions are distinguishable. + // DMs, unresolved, and unnamed channels omit the channel name. match create_session_and_apply_model( &mut agent, &ctx, @@ -2121,7 +2288,7 @@ pub async fn run_prompt_task( huddle_instructions: huddle_instructions.as_deref(), canvas: agent_canvas.as_deref(), name: title_channel.as_deref(), - id: Some(*cid), + scope: Some(scope), channel_type: origin_channel_type.as_deref(), }, ) @@ -2130,19 +2297,20 @@ pub async fn run_prompt_task( Ok(sid) => { tracing::info!( target: "pool::session", - "created session {sid} for channel {cid}" + "created session {sid} for channel {cid} (scope {})", + scope.telemetry_label() ); - agent.state.sessions.insert(*cid, sid.clone()); + agent.state.sessions.insert(scope.clone(), sid.clone()); agent .state .deliveries - .insert(*cid, ChannelDeliveryState::default()); + .insert(scope.clone(), ChannelDeliveryState::default()); // Seed a zero usage baseline: buzz-acp spawned this session // so prior usage is zero by definition — first turn is reliable. agent.acp.notify_session_spawned(&sid); // Commit canvas only after session creation succeeds (I3). - if let Some((pending_cid, section)) = pending_canvas.take() { - agent.state.canvas_sections.insert(pending_cid, section); + if let Some((pending_scope, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_scope, section); } (sid, true) } @@ -2186,7 +2354,7 @@ pub async fn run_prompt_task( huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -2255,7 +2423,7 @@ pub async fn run_prompt_task( // whenever a session is invalidated — so the replacement session re-delivers // rather than leaving the agent unbriefed. let standing = crate::queue::StandingContext { - base_prompt: ctx.base_prompt, + base_prompt: ctx.base_prompt.as_deref(), system_prompt: ctx.system_prompt.as_deref(), team_instructions: ctx.team_instructions.as_deref(), agent_core: agent_core.as_deref(), @@ -2266,17 +2434,19 @@ pub async fn run_prompt_task( // sessions created before this field existed fail safe by behaving as // undelivered once, rather than silently omitting standing context. let mut standing_context_sent = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Channel(scope) => agent .state .deliveries - .get(cid) + .get(scope) .is_some_and(|delivery| delivery.standing_context_sent), PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, }; if is_new_session { - if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) + if let (PromptSource::Channel(scope), Some(ref initial_msg)) = + (&source, &ctx.initial_message) { + let cid = &scope.channel_id(); tracing::info!( target: "pool::session", "sending initial_message to session {session_id} for channel {cid}" @@ -2310,7 +2480,9 @@ pub async fn run_prompt_task( // prompt below must not repeat it. Every other arm returns. standing_context_sent = true; if !agent.has_system_prompt_support() { - agent.state.mark_channel_delivery_success(*cid, true, []); + agent + .state + .mark_scope_delivery_success(scope.clone(), true, []); } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -2452,7 +2624,7 @@ pub async fn run_prompt_task( 1 }, &crate::queue::StandingContext { - base_prompt: ctx.base_prompt, + base_prompt: ctx.base_prompt.as_deref(), ..Default::default() }, &text, @@ -2478,7 +2650,7 @@ pub async fn run_prompt_task( let delivered_ids = agent .state .deliveries - .get(&b.channel_id) + .get(&b.scope) .map(|delivery| &delivery.delivered_event_ids) .cloned() .unwrap_or_default(); @@ -2747,11 +2919,11 @@ pub async fn run_prompt_task( ); } log_stop_reason(&source, &StopReason::EndTurn); - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); - record_channel_delivery_success( + record_scope_delivery_success( &mut agent, - *cid, + scope.clone(), standing_sent, &pending_delivered_event_ids, ); @@ -2790,11 +2962,11 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); - record_channel_delivery_success( + record_scope_delivery_success( &mut agent, - *cid, + scope.clone(), standing_sent, &pending_delivered_event_ids, ); @@ -2811,8 +2983,8 @@ pub async fn run_prompt_task( let limit = ctx.max_turns_per_session; if limit > 0 { match &source { - PromptSource::Channel(cid) => { - let count = agent.state.turn_counts.entry(*cid).or_insert(0); + PromptSource::Channel(scope) => { + let count = agent.state.turn_counts.entry(scope.clone()).or_insert(0); *count += 1; *count >= limit } @@ -3491,8 +3663,21 @@ fn conversation_context_delta( /// - The REST fetch fails or times out (graceful degradation) /// - `context_message_limit` is 0 /// -/// For batches with multiple events, thread context is fetched for the **last** -/// reply event only (most recent = most likely to need a response). +/// Context is scoped by the batch's resolved [`SessionScope`], never inferred +/// from whichever event happens to be last: +/// +/// - **Thread scope** → fetch only that canonical thread's history (all +/// messages under the root, including intervening non-mention human +/// messages). A brand-new thread (root == the triggering event, first turn) +/// has no prior history, so this returns `None`, which is correct: the +/// trigger itself is delivered as the `[Event]` block. +/// - **Conversation scope** (DMs always; channels under the `channel` policy) +/// → preserve legacy behavior: a threaded reply fetches its reply chain; +/// a DM non-reply fetches recent conversation history. +/// +/// The delivery-delta filter (`conversation_context_delta`) then removes any +/// events this scope's live session already received, so subsequent turns +/// deliver only intervening same-thread messages plus the trigger. async fn fetch_conversation_context( batch: &FlushBatch, channel_info: &Option, @@ -3504,28 +3689,54 @@ async fn fetch_conversation_context( .map(|ci| ci.channel_type == "dm") .unwrap_or(false); - // Check thread tags on the last event first — this applies to both - // channels and DMs. A DM reply needs thread context (not channel history) - // because /api/channels/{id}/messages excludes thread replies. - let last_event = batch.events.last()?; - let tags = crate::queue::parse_thread_tags(&last_event.event); - if let Some(root_id) = tags.root_event_id { - return fetch_thread_context( - batch.channel_id, - &root_id, - limit, - ctx.agent_keys.public_key(), - &ctx.rest_client, - ) - .await; + match resolve_context_target(batch, is_dm) { + ContextTarget::Thread(root_id) => { + fetch_thread_context( + batch.channel_id, + &root_id, + limit, + ctx.agent_keys.public_key(), + &ctx.rest_client, + ) + .await + } + ContextTarget::Dm => fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await, + ContextTarget::None => None, } +} + +/// Which history to fetch for a batch's context section. +#[derive(Debug, PartialEq, Eq)] +enum ContextTarget { + /// Fetch the canonical thread rooted at this event id. + Thread(String), + /// Fetch recent DM conversation history. + Dm, + /// No supplementary context (new thread's first turn, or plain channel). + None, +} - // DM non-reply: fetch recent conversation history. +/// Decide which history to gather, driven by the batch's resolved +/// [`SessionScope`] — never by inferring scope from the last event. +/// +/// - Thread scope: the canonical root is authoritative. +/// - Conversation scope (DMs always; channels under `channel` policy): a +/// threaded reply fetches its reply chain; a DM non-reply fetches recent +/// conversation history; a plain top-level channel message has none. +fn resolve_context_target(batch: &FlushBatch, is_dm: bool) -> ContextTarget { + if let Some(root_id) = batch.scope.root_event_id() { + return ContextTarget::Thread(root_id.to_string()); + } + let Some(last_event) = batch.events.last() else { + return ContextTarget::None; + }; + if let Some(root_id) = crate::queue::parse_thread_tags(&last_event.event).root_event_id { + return ContextTarget::Thread(root_id); + } if is_dm { - return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await; + return ContextTarget::Dm; } - - None + ContextTarget::None } /// Normalize AND validate a pubkey for the batch profile API request. @@ -4248,7 +4459,11 @@ fn classify_control_cancel_failure( /// Shared by the turn-start and turn-stop lines so a log can be read as pairs. fn prompt_label(source: &PromptSource) -> String { match source { - PromptSource::Channel(cid) => format!("channel {cid}"), + PromptSource::Channel(scope) => format!( + "channel {} ({})", + scope.channel_id(), + scope.telemetry_label() + ), PromptSource::Heartbeat => "heartbeat".to_string(), } } @@ -4284,19 +4499,19 @@ fn delivery_receipt_line(channel_id: Uuid, event_ids: &HashSet) -> Strin ) } -fn record_channel_delivery_success( +fn record_scope_delivery_success( agent: &mut OwnedAgent, - channel_id: Uuid, + scope: SessionScope, standing_context_sent: bool, event_ids: &HashSet, ) { tracing::info!( target: "pool::prompt", "{}", - delivery_receipt_line(channel_id, event_ids) + delivery_receipt_line(scope.channel_id(), event_ids) ); - agent.state.mark_channel_delivery_success( - channel_id, + agent.state.mark_scope_delivery_success( + scope, standing_context_sent, event_ids.iter().cloned(), ); @@ -4794,6 +5009,7 @@ pub(crate) async fn post_notice( &mention_refs, false, &[], + &[], ) { Ok(b) => b, Err(e) => { @@ -4936,6 +5152,12 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + /// Conversation scope for a channel — the scope these pool tests exercise + /// (equivalent to the pre-thread-scoping channel key). + fn conv(channel_id: Uuid) -> SessionScope { + SessionScope::Conversation { channel_id } + } + fn test_mcp_server() -> McpServer { McpServer { name: "dev".into(), @@ -6147,8 +6369,10 @@ mod tests { .sign_with_keys(&keys) .unwrap(); let author_hex = event.pubkey.to_hex(); + let channel_id = Uuid::new_v4(); let batch = FlushBatch { - channel_id: Uuid::new_v4(), + channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "@mention".into(), @@ -6282,7 +6506,7 @@ done"# agent.state.heartbeat_session = Some("live-session".into()); let mut ctx = make_prompt_context_no_owner(); - ctx.base_prompt = Some("standing-once"); + ctx.base_prompt = Some("standing-once".into()); let ctx = Arc::new(ctx); let (result_tx, mut result_rx) = mpsc::unbounded_channel(); @@ -6382,14 +6606,14 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); - ctx.base_prompt = Some("standing-once"); + ctx.base_prompt = Some("standing-once".into()); let ctx = Arc::new(ctx); let (result_tx, mut result_rx) = mpsc::unbounded_channel(); @@ -6400,6 +6624,7 @@ done"# let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -6426,7 +6651,7 @@ done"# PromptOutcome::Ok(StopReason::EndTurn) )), } - let delivery = &result.agent.state.deliveries[&channel_id]; + let delivery = &result.agent.state.deliveries[&conv(channel_id)]; assert_eq!( delivery.standing_context_sent, turn >= 2, @@ -6482,6 +6707,7 @@ done"# .unwrap(); let merged_batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: new_event.clone(), prompt_tag: "test".into(), @@ -6496,6 +6722,7 @@ done"# }; let next_batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: next_event, prompt_tag: "test".into(), @@ -6557,11 +6784,11 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); ctx.context_message_limit = 10; @@ -6603,7 +6830,7 @@ done"# )); agent = result.agent; } - let delivery = &agent.state.deliveries[&channel_id]; + let delivery = &agent.state.deliveries[&conv(channel_id)]; assert!(delivery.delivered_event_ids.contains(&carry_over_id)); assert!(delivery.delivered_event_ids.contains(&new_event_id)); agent.acp.shutdown().await; @@ -6651,6 +6878,7 @@ done"# .unwrap(); let batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: trigger, prompt_tag: "test".into(), @@ -6710,22 +6938,22 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); // Model the adversarial ordering: the task result has already retired // its TaskMeta and returned the agent before the successful ack arrives. let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &conv(channel_id), steered_event_id.clone(), "live-session".into(), )); let agent = pool - .try_claim(Some(channel_id)) + .try_claim(Some(&conv(channel_id))) .expect("claim returned agent"); let mut ctx = make_prompt_context_no_owner(); @@ -6788,19 +7016,19 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let mut state = SessionState::default(); state .deliveries - .insert(channel, ChannelDeliveryState::default()); + .insert(conv(channel), ChannelDeliveryState::default()); // Building or attempting a prompt does not mutate delivery state. - let delivery = state.deliveries.get(&channel).unwrap(); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(!delivery.standing_context_sent); assert!(delivery.delivered_event_ids.is_empty()); - state.mark_channel_delivery_success( - channel, + state.mark_scope_delivery_success( + conv(channel), true, ["trigger".to_string(), "context".to_string()], ); - let delivery = state.deliveries.get(&channel).unwrap(); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(delivery.standing_context_sent); assert_eq!(delivery.delivered_event_ids.len(), 2); } @@ -6809,17 +7037,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn delivery_state_is_cleared_on_rotation_and_restarts_empty() { let channel = Uuid::new_v4(); let mut state = SessionState::default(); - state.sessions.insert(channel, "old-session".into()); - state.mark_channel_delivery_success(channel, true, ["old-event".to_string()]); + state.sessions.insert(conv(channel), "old-session".into()); + state.mark_scope_delivery_success(conv(channel), true, ["old-event".to_string()]); - assert!(state.invalidate_channel(&channel)); - assert!(!state.deliveries.contains_key(&channel)); + assert!(state.invalidate_channel(&channel) > 0); + assert!(!state.deliveries.contains_key(&conv(channel))); - state.sessions.insert(channel, "new-session".into()); + state.sessions.insert(conv(channel), "new-session".into()); state .deliveries - .insert(channel, ChannelDeliveryState::default()); - let delivery = state.deliveries.get(&channel).unwrap(); + .insert(conv(channel), ChannelDeliveryState::default()); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(!delivery.standing_context_sent); assert!(delivery.delivered_event_ids.is_empty()); } @@ -6929,21 +7157,21 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.sessions.insert(ch_a, "sess-a".into()); - s.sessions.insert(ch_b, "sess-b".into()); - s.turn_counts.insert(ch_a, 5); - s.turn_counts.insert(ch_b, 3); - s.core_sections.insert(ch_a, "core-a".into()); - s.core_sections.insert(ch_b, "core-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); + s.sessions.insert(conv(ch_b), "sess-b".into()); + s.turn_counts.insert(conv(ch_a), 5); + s.turn_counts.insert(conv(ch_b), 3); + s.core_sections.insert(conv(ch_a), "core-a".into()); + s.core_sections.insert(conv(ch_b), "core-b".into()); s.deliveries.insert( - ch_a, + conv(ch_a), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-a".into()]), }, ); s.deliveries.insert( - ch_b, + conv(ch_b), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-b".into()]), @@ -6955,23 +7183,242 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" (s, ch_a, ch_b) } + fn thread_scope(channel_id: Uuid, root: &str) -> SessionScope { + SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + #[test] + fn two_threads_in_one_channel_get_distinct_sessions() { + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let mut s = SessionState::default(); + s.sessions.insert(ta.clone(), "sess-thread-a".into()); + s.sessions.insert(tb.clone(), "sess-thread-b".into()); + // Distinct roots key distinct provider sessions. + assert_eq!( + s.sessions.get(&ta).map(String::as_str), + Some("sess-thread-a") + ); + assert_eq!( + s.sessions.get(&tb).map(String::as_str), + Some("sess-thread-b") + ); + // Repeated activity under one root reuses that exact session. + assert_eq!( + s.sessions.get(&ta).map(String::as_str), + Some("sess-thread-a") + ); + // The conversation scope is a different key again (no accidental reuse). + assert!(!s.sessions.contains_key(&conv(ch))); + } + + #[test] + fn invalidate_scope_leaves_sibling_thread_untouched() { + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let mut s = SessionState::default(); + s.sessions.insert(ta.clone(), "a".into()); + s.sessions.insert(tb.clone(), "b".into()); + s.turn_counts.insert(ta.clone(), 2); + assert!(s.invalidate_scope(&ta)); + assert!(!s.sessions.contains_key(&ta)); + assert!(!s.turn_counts.contains_key(&ta)); + // Sibling thread's session survives. + assert_eq!(s.sessions.get(&tb).map(String::as_str), Some("b")); + } + + fn batch_with_scope(scope: SessionScope, event: nostr::Event) -> FlushBatch { + FlushBatch { + channel_id: scope.channel_id(), + scope, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + fn signed_event_with_tags(tags: Vec>) -> nostr::Event { + let keys = Keys::generate(); + let tags: Vec = tags.into_iter().map(|t| Tag::parse(t).unwrap()).collect(); + EventBuilder::new(Kind::Custom(9), "hi") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn context_target_uses_thread_scope_root_not_last_event_tags() { + let ch = Uuid::new_v4(); + let scope_root = "a".repeat(64); + // Last event carries a DIFFERENT root tag than the scope; the scope + // must win so context is gathered for the canonical thread. + let ev = signed_event_with_tags(vec![vec![ + "e".into(), + "b".repeat(64), + String::new(), + "root".into(), + ]]); + let batch = batch_with_scope(thread_scope(ch, &scope_root), ev); + assert_eq!( + resolve_context_target(&batch, false), + ContextTarget::Thread(scope_root) + ); + } + + #[test] + fn context_target_new_top_level_thread_has_no_history() { + // A top-level mention opens a thread rooted at its own id; on the first + // turn there is no prior thread history to fetch, but the scope still + // resolves to that root (subsequent turns fetch it). + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let root = ev.id.to_hex(); + let batch = batch_with_scope(thread_scope(ch, &root), ev); + assert_eq!( + resolve_context_target(&batch, false), + ContextTarget::Thread(root) + ); + } + + #[test] + fn context_target_conversation_channel_plain_has_none() { + // Channel-policy conversation scope + a plain (no-thread-tag) event => + // no unrelated channel transcript is injected. + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!(resolve_context_target(&batch, false), ContextTarget::None); + } + + #[test] + fn context_target_dm_nonreply_is_dm_history() { + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!(resolve_context_target(&batch, true), ContextTarget::Dm); + } + + #[test] + fn context_target_conversation_reply_uses_reply_chain() { + // DM (or legacy channel-policy) reply: conversation scope but the last + // event has thread tags => fetch that reply chain. + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + let ev = signed_event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "d".repeat(64), String::new(), "reply".into()], + ]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!( + resolve_context_target(&batch, true), + ContextTarget::Thread(root) + ); + } + + #[test] + fn invalidate_channel_clears_every_thread_scope() { + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + let mut s = SessionState::default(); + s.sessions + .insert(thread_scope(ch, &"a".repeat(64)), "a".into()); + s.sessions + .insert(thread_scope(ch, &"b".repeat(64)), "b".into()); + s.sessions.insert(conv(ch), "c".into()); + s.sessions + .insert(thread_scope(other, &"d".repeat(64)), "d".into()); + let cleared = s.invalidate_channel(&ch); + assert_eq!(cleared, 3, "all three ch scopes had sessions"); + assert!(s.sessions.keys().all(|k| k.channel_id() == other)); + } + + #[test] + fn prompt_source_scope_exposes_thread_scope_and_none_for_heartbeat() { + let ch = Uuid::new_v4(); + let scope = thread_scope(ch, &"a".repeat(64)); + let channel = PromptSource::Channel(scope.clone()); + // The scope-precise accessor returns the exact thread so a completing + // turn clears only its own typing indicator. + assert_eq!(channel.scope(), Some(&scope)); + assert_eq!(channel.channel_id(), Some(ch)); + assert_eq!(PromptSource::Heartbeat.scope(), None); + } + + #[tokio::test] + async fn invalidate_scope_session_targets_one_thread_and_drops_its_owner() { + // The idle `!rotate` path: rotating thread A must invalidate only thread + // A's session and drop its scope-owner entry, leaving a sibling thread + // in the same channel fully intact. + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let acp = AcpClient::spawn("bash", &["-c".into(), "sleep 10".into()], &[], false) + .await + .expect("spawn dummy ACP"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "test".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + agent.state.sessions.insert(ta.clone(), "sess-a".into()); + agent.state.sessions.insert(tb.clone(), "sess-b".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.record_scope_owner(ta.clone(), 0); + pool.record_scope_owner(tb.clone(), 0); + + let cleared = pool.invalidate_scope_session(&ta); + + assert_eq!(cleared, 1, "exactly one worker held thread A's session"); + assert!(!pool.has_session_for(&ta), "thread A session invalidated"); + assert!( + pool.has_session_for(&tb), + "sibling thread B session survives" + ); + assert!( + !pool.session_owners.contains_key(&ta), + "thread A owner dropped" + ); + assert!( + pool.session_owners.contains_key(&tb), + "thread B owner retained" + ); + } + #[test] fn test_rotate_after_natural_completion_invalidates_channel_state() { let (mut s, ch_a, ch_b) = make_state(); apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::Rotate, ); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); } @@ -6982,29 +7429,31 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::Cancel, ); - assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a"); - assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); + assert_eq!(s.sessions.get(&conv(ch_a)).unwrap(), "sess-a"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); } #[test] fn test_invalidate_channel_clears_session_and_turn_count() { let (mut s, ch_a, ch_b) = make_state(); - s.invalidate(&PromptSource::Channel(ch_a)); + s.invalidate(&PromptSource::Channel(SessionScope::Conversation { + channel_id: ch_a, + })); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); // ch_b untouched - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -7020,10 +7469,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(!s.heartbeat_standing_context_sent); // channels untouched assert_eq!(s.sessions.len(), 2); - assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } #[test] @@ -7043,15 +7492,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_nonexistent_channel_is_noop() { let (mut s, ch_a, ch_b) = make_state(); let ghost = Uuid::new_v4(); - s.invalidate(&PromptSource::Channel(ghost)); + s.invalidate(&PromptSource::Channel(SessionScope::Conversation { + channel_id: ghost, + })); // Everything still intact. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); - assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } #[test] @@ -7066,15 +7517,15 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn test_invalidate_channel_returns_true_when_session_existed() { let (mut s, ch_a, ch_b) = make_state(); - assert!(s.invalidate_channel(&ch_a)); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(s.invalidate_channel(&ch_a) > 0); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); // ch_b untouched - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -7084,7 +7535,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_channel_returns_false_when_no_session() { let (mut s, _ch_a, _ch_b) = make_state(); let ghost = Uuid::new_v4(); - assert!(!s.invalidate_channel(&ghost)); + assert_eq!(s.invalidate_channel(&ghost), 0); // Nothing changed. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); @@ -7099,13 +7550,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" for ch in &removed { s.invalidate_channel(ch); } - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } // ── ControlSignal::SwitchModel (Phase 3a, Option ii) ───────────────────── @@ -7118,7 +7569,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" // re-creates a fresh session that re-applies the new desired_model. apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::SwitchModel { model_id: "gpt-5".into(), request_id: None, @@ -7127,8 +7578,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(!s.has_channel_state(&ch_a)); // ch_b untouched — the switch is channel-scoped. - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); } // ── requeue_cancelled_batch ──────────────────────────────────────────── @@ -7146,6 +7597,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" .unwrap(); FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -8367,14 +8819,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_channel_clears_canvas_section() { let ch = Uuid::new_v4(); let mut s = SessionState::default(); - s.sessions.insert(ch, "sess".into()); + s.sessions.insert(conv(ch), "sess".into()); s.canvas_sections - .insert(ch, "[Channel Canvas]\nrev abc".into()); + .insert(conv(ch), "[Channel Canvas]\nrev abc".into()); s.invalidate_channel(&ch); - assert!(!s.canvas_sections.contains_key(&ch)); - assert!(!s.sessions.contains_key(&ch)); + assert!(!s.canvas_sections.contains_key(&conv(ch))); + assert!(!s.sessions.contains_key(&conv(ch))); } #[test] @@ -8382,9 +8834,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.canvas_sections.insert(ch_a, "canvas-a".into()); - s.canvas_sections.insert(ch_b, "canvas-b".into()); - s.sessions.insert(ch_a, "sess-a".into()); + s.canvas_sections.insert(conv(ch_a), "canvas-a".into()); + s.canvas_sections.insert(conv(ch_b), "canvas-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); s.invalidate_all(); @@ -8397,22 +8849,22 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.sessions.insert(ch_a, "sess-a".into()); - s.sessions.insert(ch_b, "sess-b".into()); - s.canvas_sections.insert(ch_a, "canvas-a".into()); - s.canvas_sections.insert(ch_b, "canvas-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); + s.sessions.insert(conv(ch_b), "sess-b".into()); + s.canvas_sections.insert(conv(ch_a), "canvas-a".into()); + s.canvas_sections.insert(conv(ch_b), "canvas-b".into()); s.invalidate_channel(&ch_a); - assert!(!s.canvas_sections.contains_key(&ch_a)); - assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b"); + assert!(!s.canvas_sections.contains_key(&conv(ch_a))); + assert_eq!(s.canvas_sections.get(&conv(ch_b)).unwrap(), "canvas-b"); } #[test] fn test_has_channel_state_true_when_only_canvas_section_present() { let ch = Uuid::new_v4(); let mut s = SessionState::default(); - s.canvas_sections.insert(ch, "canvas".into()); + s.canvas_sections.insert(conv(ch), "canvas".into()); assert!(s.has_channel_state(&ch)); } @@ -8893,6 +9345,7 @@ done"# let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id, + scope: conv(channel_id), events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -9341,7 +9794,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9378,7 +9831,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9412,7 +9865,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9445,7 +9898,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9485,7 +9938,7 @@ exit 0"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9585,6 +10038,154 @@ done"# // agent wants to switch to. const OPTS_MODEL_A_AND_B: &str = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#; + #[tokio::test] + async fn session_new_sends_policy_specific_base_and_scope_specific_title() { + use crate::scope::SessionPolicy; + + let channel_id = Uuid::new_v4(); + let thread_a = SessionScope::Thread { + channel_id, + root_event_id: "abcdef01".repeat(8), + }; + let thread_b = SessionScope::Thread { + channel_id, + root_event_id: "12345678".repeat(8), + }; + let conversation = SessionScope::Conversation { channel_id }; + for (policy, scope, name, channel_type, title) in [ + ( + SessionPolicy::Channel, + Some(&conversation), + Some("engineering"), + Some("stream"), + "Fizz · #engineering", + ), + ( + SessionPolicy::Thread, + Some(&thread_a), + Some("engineering"), + Some("stream"), + "Fizz · #engineering · abcdef01", + ), + ( + SessionPolicy::Thread, + Some(&thread_b), + Some("engineering"), + Some("stream"), + "Fizz · #engineering · 12345678", + ), + ( + SessionPolicy::Thread, + Some(&conversation), + None, + Some("dm"), + "Fizz", + ), + (SessionPolicy::Thread, None, None, None, "Fizz"), + ] { + for (version, include_base) in [(1, true), (2, true), (1, false), (2, false)] { + let acp = spawn_switch_acp("[]", r#""result":{}"#).await; + let mut agent = switching_agent(acp, "unused"); + agent.desired_model = None; + agent.protocol_version = version; + let observer = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(observer.clone()), 0); + let mut ctx = make_prompt_context_no_owner(); + ctx.session_title = Some("Fizz".into()); + ctx.base_prompt = + include_base.then(|| policy.append_session_model("Custom base instructions.")); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name, + scope, + channel_type, + }, + ) + .await + .unwrap(); + let request = observer + .snapshot() + .into_iter() + .find(|event| { + event.kind == "acp_write" && event.payload["method"] == "session/new" + }) + .unwrap() + .payload; + assert_eq!(request["params"]["_meta"]["sessionTitle"], title); + let base = ctx + .base_prompt + .as_deref() + .map(crate::queue::base_section) + .unwrap_or_default(); + if !include_base { + assert!(request["params"].get("systemPrompt").is_none()); + } else if version == 2 { + let system = request["params"]["systemPrompt"].as_str().unwrap(); + assert!(system.starts_with(&base)); + assert_eq!(system.matches("## Session Model").count(), 1); + } else { + assert!(request["params"].get("systemPrompt").is_none()); + let legacy = prepend_standing_for_legacy( + version, + &crate::queue::StandingContext { + base_prompt: ctx.base_prompt.as_deref(), + ..Default::default() + }, + "hello", + ); + assert!(legacy.starts_with(&base)); + assert_eq!(legacy.matches("## Session Model").count(), 1); + } + agent.acp.shutdown().await; + } + } + } + + #[tokio::test] + async fn idle_channel_switch_preserves_all_sibling_sessions_and_model() { + let channel_id = Uuid::new_v4(); + let scopes = ["a", "b"].map(|root| SessionScope::Thread { + channel_id, + root_event_id: root.repeat(64), + }); + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{}"#).await; + let mut agent = switching_agent(acp, "model-a"); + for scope in &scopes { + agent + .state + .sessions + .insert(scope.clone(), scope.telemetry_label()); + } + let original_sessions = agent.state.sessions.clone(); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + assert_eq!( + pool.switch_idle_agent_model(channel_id, "model-b", Some("pick".into())), + IdleSwitchResult::AmbiguousTarget, + ); + let agent = pool.agents[0].as_ref().unwrap(); + assert_eq!(agent.desired_model.as_deref(), Some("model-a")); + assert_eq!(agent.desired_model_request_id, None); + assert_eq!(agent.state.sessions, original_sessions); + + // One remaining session is an unambiguous channel control again. The + // selected scope and its owner are cleared without broad channel cleanup. + pool.invalidate_scope_session(&scopes[1]); + pool.record_scope_owner(scopes[0].clone(), 0); + assert_eq!( + pool.switch_idle_agent_model(channel_id, "model-b", Some("pick".into())), + IdleSwitchResult::Switched, + ); + let agent = pool.agents[0].as_ref().unwrap(); + assert_eq!(agent.desired_model.as_deref(), Some("model-b")); + assert!(!agent.state.sessions.contains_key(&scopes[0])); + assert!(!pool.session_owners.contains_key(&scopes[0])); + } + #[tokio::test] async fn test_applied_switch_refreshes_capabilities_from_post_switch_snapshot() { // The adapter accepts the switch and echoes the target model's rebuilt @@ -9612,7 +10213,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9683,7 +10284,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9738,7 +10339,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9780,7 +10381,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9821,7 +10422,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9887,7 +10488,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9924,7 +10525,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9997,7 +10598,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -10038,7 +10639,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 2e0e7854e69..539f6e2cd50 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -21,10 +21,62 @@ use uuid::Uuid; use crate::prompt_project::PromptProjectInfo; use crate::config::DedupMode; +use crate::scope::SessionScope; -/// Maximum events queued per channel before oldest events are dropped. +/// Maximum events queued per session scope before oldest events are dropped. +/// +/// Under the `channel` policy there is exactly one scope per channel, so this +/// is the historical per-channel cap. Under the `thread` policy it caps each +/// thread partition; the channel as a whole is additionally bounded by +/// [`MAX_PENDING_PER_CHANNEL`] so per-thread partitioning cannot multiply the +/// total admitted backlog. +const MAX_PENDING_PER_SCOPE: usize = 500; + +/// Aggregate cap on events queued across ALL scopes of a single channel. +/// +/// Preserves the pre-thread-scoping backlog protection: moving the per-scope +/// limit to “per thread” must not let one channel with many threads hold an +/// unbounded multiple of the old cap. Equal to [`MAX_PENDING_PER_SCOPE`] so a +/// single-scope channel behaves exactly as before. const MAX_PENDING_PER_CHANNEL: usize = 500; +/// A key that identifies a queue partition (session scope). +/// +/// Lets the queue's public API accept either a bare channel [`Uuid`] (treated +/// as a conversation scope — the pre-thread-scoping default, and what the +/// queue's own unit tests use) or an explicit [`SessionScope`] (what the +/// harness passes once a thread scope has been resolved at admission). This +/// keeps the large existing channel-keyed test suite compiling unchanged while +/// the hot path routes by full scope. +pub trait IntoScope { + /// Convert into the owned [`SessionScope`] used as the partition key. + fn into_scope(self) -> SessionScope; +} + +impl IntoScope for SessionScope { + fn into_scope(self) -> SessionScope { + self + } +} + +impl IntoScope for &SessionScope { + fn into_scope(self) -> SessionScope { + self.clone() + } +} + +impl IntoScope for Uuid { + fn into_scope(self) -> SessionScope { + SessionScope::Conversation { channel_id: self } + } +} + +impl IntoScope for &Uuid { + fn into_scope(self) -> SessionScope { + SessionScope::Conversation { channel_id: *self } + } +} + /// Maximum events drained into a single batch. const MAX_BATCH_EVENTS: usize = 50; @@ -47,6 +99,11 @@ const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; #[derive(Debug, Clone)] pub struct QueuedEvent { pub channel_id: Uuid, + /// Session scope resolved once at admission. Under `channel` policy this is + /// always `Conversation { channel_id }`; under `thread` policy it is the + /// canonical thread scope. The queue partitions on this, never on the + /// channel alone. Invariant: `scope.channel_id() == channel_id`. + pub scope: SessionScope, pub event: Event, pub received_at: Instant, /// Tag identifying which rule (or mode) matched this event. @@ -78,6 +135,9 @@ pub enum CancelReason { #[derive(Debug, Clone)] pub struct FlushBatch { pub channel_id: Uuid, + /// The single session scope every event in this batch belongs to. Events + /// from different scopes are never combined into one batch. + pub scope: SessionScope, pub events: Vec, /// Events from a cancelled batch that triggered this re-prompt. /// Empty for normal (non-cancel) batches. When non-empty, `format_prompt()` @@ -161,26 +221,26 @@ impl DropCounts { } pub struct EventQueue { - queues: HashMap>, - in_flight_channels: HashSet, + queues: HashMap>, + in_flight_scopes: HashSet, /// Running totals of work discarded before delivery. See [`DropCounts`]. drops: DropCounts, - /// Per-channel deadline for auto-expiring stuck in-flight entries. - in_flight_deadlines: HashMap, + /// Per-scope deadline for auto-expiring stuck in-flight entries. + in_flight_deadlines: HashMap, /// Number of events in each in-flight batch (for expiry logging). - in_flight_batch_sizes: HashMap, - retry_after: HashMap, - /// Per-channel retry attempt counter for exponential backoff / dead-lettering. - retry_counts: HashMap, + in_flight_batch_sizes: HashMap, + retry_after: HashMap, + /// Per-scope retry attempt counter for exponential backoff / dead-lettering. + retry_counts: HashMap, dedup_mode: DedupMode, /// Events from cancelled batches, keyed by channel. Merged into the next /// `FlushBatch` for that channel as `cancelled_events` so `format_prompt()` /// can produce annotated "[Previous request — interrupted]" sections. - cancelled_batches: HashMap>, - /// Why each channel's cancelled batch was cancelled (steer vs interrupt). + cancelled_batches: HashMap>, + /// Why each scope's cancelled batch was cancelled (steer vs interrupt). /// Set by `requeue_as_cancelled`, consumed by `flush_next` to set - /// `FlushBatch::cancel_reason`. Keyed by channel, cleared on flush. - cancel_reasons: HashMap, + /// `FlushBatch::cancel_reason`. Keyed by scope, cleared on flush. + cancel_reasons: HashMap, /// Events withheld from `queues` while a goose-native steer is in flight /// for that event. Invisible to `flush_next` / `has_flushable_work` / /// `drain` (the events have been moved out of `queues`), so the queue's @@ -191,7 +251,7 @@ pub struct EventQueue { /// at line 453). Bulk recovery on in-flight deadline expiry is performed /// by `flush_next` / `has_flushable_work` (recover, not log-and-drop — /// the events were never delivered to the agent). - withheld_native_steer: HashMap>, + withheld_native_steer: HashMap>, /// Duration after which an in-flight channel is auto-expired as orphaned. /// Must be strictly greater than `max_turn_duration` so a turn running to /// the hard cap returns via `mark_complete` before the backstop fires. @@ -207,7 +267,7 @@ impl EventQueue { pub fn new(dedup_mode: DedupMode) -> Self { Self { queues: HashMap::new(), - in_flight_channels: HashSet::new(), + in_flight_scopes: HashSet::new(), drops: DropCounts::default(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), @@ -236,13 +296,15 @@ impl EventQueue { /// moves backward. If the channel is not in-flight (already completed /// via `mark_complete`), this is a no-op: a late ack never resurrects /// a deadline. - pub fn extend_in_flight_deadline(&mut self, channel_id: Uuid, max_turn_secs: u64) { - if let Some(current) = self.in_flight_deadlines.get_mut(&channel_id) { + pub fn extend_in_flight_deadline(&mut self, scope: K, max_turn_secs: u64) { + let scope = scope.into_scope(); + if let Some(current) = self.in_flight_deadlines.get_mut(&scope) { let extended = Instant::now() + Duration::from_secs(max_turn_secs + IN_FLIGHT_DEADLINE_BUFFER_SECS); if extended > *current { tracing::info!( - %channel_id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), "extending in-flight deadline by {max_turn_secs}s + {IN_FLIGHT_DEADLINE_BUFFER_SECS}s buffer" ); *current = extended; @@ -257,8 +319,13 @@ impl EventQueue { /// /// Returns `true` if the event was accepted, `false` if dropped. pub fn push(&mut self, event: QueuedEvent) -> bool { + debug_assert_eq!( + event.scope.channel_id(), + event.channel_id, + "QueuedEvent.scope must belong to its channel_id" + ); if matches!(self.dedup_mode, DedupMode::Drop) - && self.in_flight_channels.contains(&event.channel_id) + && self.in_flight_scopes.contains(&event.scope) { self.drops.in_flight_dedup += 1; // warn, not debug: this discards a request someone sent. At debug @@ -266,28 +333,71 @@ impl EventQueue { // missing without anyone being able to say so afterwards. tracing::warn!( channel_id = %event.channel_id, + scope = %event.scope.telemetry_label(), dropped_total = self.drops.in_flight_dedup, - "dropping event for in-flight channel (drop mode)" + "dropping event for in-flight scope (drop mode)" ); return false; } let drops = &mut self.drops; - let queue = self.queues.entry(event.channel_id).or_default(); - // Enforce per-channel depth cap: drop oldest to make room. - if queue.len() >= MAX_PENDING_PER_CHANNEL { + let channel_id = event.channel_id; + let scope = event.scope.clone(); + let queue = self.queues.entry(scope.clone()).or_default(); + // Enforce per-scope depth cap: drop oldest in this partition. + if queue.len() >= MAX_PENDING_PER_SCOPE { queue.pop_front(); drops.queue_depth_cap += 1; tracing::warn!( - channel_id = %event.channel_id, - limit = MAX_PENDING_PER_CHANNEL, + channel_id = %channel_id, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, dropped_total = drops.queue_depth_cap, - "queue depth cap reached — dropped oldest event" + "per-scope queue depth cap reached — dropped oldest event" ); } queue.push_back(event); + // Enforce the aggregate per-channel cap across all scopes so thread + // partitioning cannot multiply the admitted backlog. + self.enforce_channel_cap(channel_id); true } + /// Total queued events across every scope belonging to `channel_id`. + fn channel_event_total(&self, channel_id: Uuid) -> usize { + self.queues + .iter() + .filter(|(s, _)| s.channel_id() == channel_id) + .map(|(_, q)| q.len()) + .sum() + } + + /// Drop the globally-oldest queued event(s) across a channel's scopes until + /// its aggregate depth is within [`MAX_PENDING_PER_CHANNEL`]. Preserves + /// cross-scope FIFO fairness by always evicting the oldest head event. + fn enforce_channel_cap(&mut self, channel_id: Uuid) { + while self.channel_event_total(channel_id) > MAX_PENDING_PER_CHANNEL { + // Find the channel's scope whose head event is oldest. + let victim = self + .queues + .iter() + .filter(|(s, q)| s.channel_id() == channel_id && !q.is_empty()) + .min_by_key(|(_, q)| q.front().unwrap().received_at) + .map(|(s, _)| s.clone()); + let Some(scope) = victim else { break }; + if let Some(q) = self.queues.get_mut(&scope) { + q.pop_front(); + if q.is_empty() { + self.queues.remove(&scope); + } + } + tracing::warn!( + channel_id = %channel_id, + limit = MAX_PENDING_PER_CHANNEL, + "aggregate per-channel queue cap reached — dropped oldest event" + ); + } + } + /// Try to flush the next batch. /// /// Returns `None` if all non-in-flight, non-throttled queues are empty. @@ -298,67 +408,70 @@ impl EventQueue { let now = Instant::now(); // Auto-expire any stuck in-flight entries that missed mark_complete. - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) + .map(|(scope, _)| scope.clone()) .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); + for scope in expired { + let lost_events = self.in_flight_batch_sizes.remove(&scope).unwrap_or(0); tracing::error!( - channel_id = %id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight scope expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); // Recover any withheld goose-native steer events for the expired - // channel back to the queue front so normal dispatch delivers + // scope back to the queue front so normal dispatch delivers // them. Unlike the in-flight batch above (already delivered to a // now-hung prompt — nothing to recover), these events were never // delivered to the agent. - self.recover_withheld_for_expired_channel(id); + self.recover_withheld_for_expired_scope(&scope); } - // Find the channel whose head event has the oldest received_at, - // excluding in-flight channels and throttled channels. - let channel_id = self + // Find the scope whose head event has the oldest received_at, + // excluding in-flight scopes and throttled scopes. + let scope = self .queues .iter() - .filter(|(id, q)| { + .filter(|(scope, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_scopes.contains(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) .min_by_key(|(_, q)| q.front().unwrap().received_at) - .map(|(id, _)| *id); + .map(|(scope, _)| scope.clone()); - // Fallback: if no queued events are ready but a channel has cancelled + // Fallback: if no queued events are ready but a scope has cancelled // events waiting (e.g., explicit !cancel with no new @mention), flush // those as a regular batch (re-dispatch unchanged). - let channel_id = match channel_id { - Some(id) => id, + let scope = match scope { + Some(scope) => scope, None => { - let cancelled_id = self + let cancelled_scope = self .cancelled_batches .keys() - .find(|id| !self.in_flight_channels.contains(id)) - .copied(); - match cancelled_id { - Some(id) => { + .find(|scope| !self.in_flight_scopes.contains(scope)) + .cloned(); + match cancelled_scope { + Some(scope) => { // Move cancelled events into the regular events slot. // No new events to merge — re-dispatch the original batch. - let cancelled = self.cancelled_batches.remove(&id).unwrap_or_default(); - let cancel_reason = self.cancel_reasons.remove(&id); - self.in_flight_channels.insert(id); + let cancelled = self.cancelled_batches.remove(&scope).unwrap_or_default(); + let cancel_reason = self.cancel_reasons.remove(&scope); + self.in_flight_scopes.insert(scope.clone()); self.in_flight_deadlines - .insert(id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(id, cancelled.len()); + .insert(scope.clone(), now + self.in_flight_deadline); + self.in_flight_batch_sizes + .insert(scope.clone(), cancelled.len()); return Some(FlushBatch { - channel_id: id, + channel_id: scope.channel_id(), + scope, events: cancelled, cancelled_events: vec![], cancel_reason, @@ -368,9 +481,10 @@ impl EventQueue { } } }; + let channel_id = scope.channel_id(); // Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue. - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); let drain_count = MAX_BATCH_EVENTS.min(queue.len()); let mut events: Vec = queue .drain(..drain_count) @@ -387,29 +501,28 @@ impl EventQueue { events.sort_by_key(|be| be.event.created_at); // Remove the queue entry if now empty. - if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) { - self.queues.remove(&channel_id); + if self.queues.get(&scope).is_some_and(|q| q.is_empty()) { + self.queues.remove(&scope); } - self.in_flight_channels.insert(channel_id); + self.in_flight_scopes.insert(scope.clone()); self.in_flight_deadlines - .insert(channel_id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(channel_id, events.len()); + .insert(scope.clone(), now + self.in_flight_deadline); + self.in_flight_batch_sizes + .insert(scope.clone(), events.len()); // Merge any cancelled events stored by requeue_as_cancelled(). - let cancelled_events = self - .cancelled_batches - .remove(&channel_id) - .unwrap_or_default(); + let cancelled_events = self.cancelled_batches.remove(&scope).unwrap_or_default(); let cancel_reason = if cancelled_events.is_empty() { - self.cancel_reasons.remove(&channel_id); + self.cancel_reasons.remove(&scope); None } else { - self.cancel_reasons.remove(&channel_id) + self.cancel_reasons.remove(&scope) }; Some(FlushBatch { channel_id, + scope, events, cancelled_events, cancel_reason, @@ -426,22 +539,23 @@ impl EventQueue { /// so the backoff sequence continues on the next attempt. /// /// Also cleans up any already-expired `retry_after` entry. - pub fn mark_complete(&mut self, channel_id: Uuid) { - self.in_flight_channels.remove(&channel_id); - self.in_flight_deadlines.remove(&channel_id); - self.in_flight_batch_sizes.remove(&channel_id); + pub fn mark_complete(&mut self, scope: K) { + let scope = scope.into_scope(); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); + self.in_flight_batch_sizes.remove(&scope); let now = Instant::now(); - match self.retry_after.get(&channel_id) { - // Active throttle → channel was requeued; keep retry_counts intact. + match self.retry_after.get(&scope) { + // Active throttle → scope was requeued; keep retry_counts intact. Some(&deadline) if deadline > now => {} // Expired or absent throttle → successful completion; reset counter // and clean up the stale retry_after entry. Some(_) => { - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); + self.retry_after.remove(&scope); + self.retry_counts.remove(&scope); } None => { - self.retry_counts.remove(&channel_id); + self.retry_counts.remove(&scope); } } } @@ -465,8 +579,9 @@ impl EventQueue { /// `mark_complete` separately. pub fn requeue(&mut self, batch: FlushBatch) -> Option { let channel_id = batch.channel_id; + let scope = batch.scope.clone(); let attempt = { - let count = self.retry_counts.entry(channel_id).or_insert(0); + let count = self.retry_counts.entry(scope.clone()).or_insert(0); *count += 1; *count }; @@ -480,10 +595,10 @@ impl EventQueue { MAX_RETRIES, batch.events.len(), ); - self.retry_counts.remove(&channel_id); - // Also clear retry_after so fresh traffic on this channel isn't + self.retry_counts.remove(&scope); + // Also clear retry_after so fresh traffic on this scope isn't // throttled by stale backoff from the discarded poison batch. - self.retry_after.remove(&channel_id); + self.retry_after.remove(&scope); return Some(batch); } @@ -509,60 +624,92 @@ impl EventQueue { "requeueing failed batch with backoff" ); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, // preserve original timestamp (#46) }); } - // Enforce per-channel cap: trim oldest (back) events if requeue pushed - // the queue over the limit. Without this, repeated requeue+push cycles - // can grow the queue unboundedly. - while queue.len() > MAX_PENDING_PER_CHANNEL { + // Enforce per-scope cap: trim oldest (back) events if requeue pushed + // the partition over the limit. Without this, repeated requeue+push + // cycles can grow the queue unboundedly. + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "requeue overflow — dropped oldest event to enforce cap" ); } - self.retry_after.insert(channel_id, Instant::now() + delay); + self.retry_after.insert(scope, Instant::now() + delay); + self.enforce_channel_cap(channel_id); None } - /// Re-queue a batch preserving original `received_at` timestamps. + /// Re-queue a **complete** flushed batch preserving original `received_at` + /// timestamps. + /// + /// Used when a batch was flushed but could not run — no agent was available, + /// or the batch's session-owning worker was busy (thread-scope affinity + /// hold) — so we retry without penalizing the scope's fairness position and + /// without imposing a retry throttle. /// - /// Used when a batch was flushed but no agent was available — we want to - /// retry without penalizing the channel's position in the fairness queue - /// and without imposing a retry throttle. + /// Restores the **entire** batch, not just `events`: any + /// [`cancelled_events`](FlushBatch::cancelled_events) and their + /// [`cancel_reason`](FlushBatch::cancel_reason) are returned to the pending + /// cancelled-carryover so the next flush reconstructs the same merged + /// (interrupt/steer) prompt. Dropping them here would silently lose the + /// original request of an interrupted turn. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` — + /// Does NOT set `retry_after`. Does NOT remove from `in_flight_scopes` — /// caller must call `mark_complete` separately. pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) { let channel_id = batch.channel_id; - let queue = self.queues.entry(channel_id).or_default(); + let scope = batch.scope.clone(); + + // Restore cancelled carryover FIRST so it precedes any carryover a + // concurrent cancel may have already staged for this scope, preserving + // original-before-newer ordering. `flush_next` re-merges it as the next + // batch's `cancelled_events`. + if !batch.cancelled_events.is_empty() { + let existing = self.cancelled_batches.remove(&scope).unwrap_or_default(); + let mut restored = batch.cancelled_events; + restored.extend(existing); + self.cancelled_batches.insert(scope.clone(), restored); + if let Some(reason) = batch.cancel_reason { + // Keep the most recent reason if one was already staged. + self.cancel_reasons.entry(scope.clone()).or_insert(reason); + } + } + + let queue = self.queues.entry(scope.clone()).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, }); } - // Enforce per-channel cap: trim newest (back) events if over limit. - while queue.len() > MAX_PENDING_PER_CHANNEL { + // Enforce per-scope cap: trim newest (back) events if over limit. + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "requeue_preserve overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); } /// Requeue a cancelled batch so its events appear as `cancelled_events` @@ -577,11 +724,12 @@ impl EventQueue { /// the generic queue — they are stored separately and merged by /// `flush_next()`. No retry throttle, no backoff. pub fn requeue_as_cancelled(&mut self, batch: FlushBatch, reason: CancelReason) { - let entry = self.cancelled_batches.entry(batch.channel_id).or_default(); + let scope = batch.scope.clone(); + let entry = self.cancelled_batches.entry(scope.clone()).or_default(); // Preserve any already-cancelled events from a prior cancel (double-cancel). entry.extend(batch.cancelled_events); entry.extend(batch.events); - self.cancel_reasons.insert(batch.channel_id, reason); + self.cancel_reasons.insert(scope, reason); } /// Returns `true` if any channel has pending events that are not in-flight @@ -594,37 +742,38 @@ impl EventQueue { let now = Instant::now(); // Auto-expire stuck in-flight entries (same logic as flush_next). - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) + .map(|(scope, _)| scope.clone()) .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); + for scope in expired { + let lost_events = self.in_flight_batch_sizes.remove(&scope).unwrap_or(0); tracing::error!( - channel_id = %id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight scope expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); // Symmetric with the flush_next expiry block: recover withheld - // goose-native steer events for the expired channel so they are + // goose-native steer events for the expired scope so they are // not permanently orphaned in the side table. - self.recover_withheld_for_expired_channel(id); + self.recover_withheld_for_expired_scope(&scope); } - self.queues.iter().any(|(id, q)| { + self.queues.iter().any(|(scope, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_scopes.contains(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) || self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)) + .any(|scope| !self.in_flight_scopes.contains(scope)) } /// Returns `true` if any undispatched work remains for a channel that is @@ -648,19 +797,18 @@ impl EventQueue { let has_queued = self .queues .iter() - .any(|(id, q)| !q.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, q)| !q.is_empty() && !self.in_flight_scopes.contains(scope)); let has_cancelled = self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)); + .any(|scope| !self.in_flight_scopes.contains(scope)); let has_withheld = self .withheld_native_steer .iter() - .any(|(id, v)| !v.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, v)| !v.is_empty() && !self.in_flight_scopes.contains(scope)); has_queued || has_cancelled || has_withheld } - /// Number of channels with pending events. /// Running totals of work discarded before delivery. /// /// Exposed so a supervisor outside this queue can observe the loss. A @@ -670,14 +818,19 @@ impl EventQueue { self.drops } + /// Number of pending partitions (session scopes) with queued events. + /// + /// Under `channel` policy this equals the number of channels with pending + /// events; under `thread` policy it counts distinct thread partitions. pub fn pending_channels(&self) -> usize { self.queues.len() } - /// Number of queued events for a specific channel. Test-only. + /// Number of queued events for a specific scope (or channel, treated as its + /// conversation scope). Test-only. #[cfg(test)] - pub fn queued_event_count(&self, channel_id: &Uuid) -> usize { - self.queues.get(channel_id).map_or(0, |q| q.len()) + pub fn queued_event_count(&self, scope: K) -> usize { + self.queues.get(&scope.into_scope()).map_or(0, |q| q.len()) } /// Force a channel's retry-attempt counter to `count`, simulating `count` @@ -686,8 +839,8 @@ impl EventQueue { /// Test-only — lets integration tests outside this module exercise /// `requeue()`'s dead-letter threshold directly. #[cfg(test)] - pub fn set_retry_count_for_test(&mut self, channel_id: Uuid, count: u32) { - self.retry_counts.insert(channel_id, count); + pub fn set_retry_count_for_test(&mut self, scope: K, count: u32) { + self.retry_counts.insert(scope.into_scope(), count); } /// Drop all queued (non-in-flight) events for a channel. @@ -702,32 +855,47 @@ impl EventQueue { /// Returns the event IDs of dropped events so the caller can clean up /// any reactions (👀) that were added at queue-push time. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { - let ids = self + // Channel-wide cleanup must find and clear EVERY child thread scope for + // this channel, not just the conversation scope. + let scopes: Vec = self .queues - .remove(&channel_id) - .map(|q| q.into_iter().map(|e| e.event.id.to_hex()).collect()) - .unwrap_or_default(); - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); - self.cancelled_batches.remove(&channel_id); - self.cancel_reasons.remove(&channel_id); - self.withheld_native_steer.remove(&channel_id); - // Preserve in_flight_channels AND in_flight_deadlines: the in-flight + .keys() + .filter(|s| s.channel_id() == channel_id) + .cloned() + .collect(); + let mut ids = Vec::new(); + for scope in &scopes { + if let Some(q) = self.queues.remove(scope) { + ids.extend(q.into_iter().map(|e| e.event.id.to_hex())); + } + } + // Also purge side-tables for every scope of this channel. + self.retry_after.retain(|s, _| s.channel_id() != channel_id); + self.retry_counts + .retain(|s, _| s.channel_id() != channel_id); + self.cancelled_batches + .retain(|s, _| s.channel_id() != channel_id); + self.cancel_reasons + .retain(|s, _| s.channel_id() != channel_id); + self.withheld_native_steer + .retain(|s, _| s.channel_id() != channel_id); + // Preserve in_flight_scopes AND in_flight_deadlines: the in-flight // task will eventually complete (calling mark_complete) or the deadline - // will expire (auto-cleaning the channel). Removing deadlines without - // removing in_flight_channels would disable auto-expiry and leave a - // wedged task permanently blocking the channel. + // will expire (auto-cleaning the scope). Removing deadlines without + // removing in_flight_scopes would disable auto-expiry and leave a + // wedged task permanently blocking the scope. ids } - /// Whether a prompt is currently in-flight for the given channel. - pub fn is_channel_in_flight(&self, channel_id: Uuid) -> bool { - self.in_flight_channels.contains(&channel_id) + /// Whether a prompt is currently in-flight for the given scope (or channel, + /// treated as its conversation scope). + pub fn is_scope_in_flight(&self, scope: K) -> bool { + self.in_flight_scopes.contains(&scope.into_scope()) } - /// Whether any channel currently has a turn in flight. + /// Whether any scope currently has a turn in flight. pub fn has_in_flight(&self) -> bool { - !self.in_flight_channels.is_empty() + !self.in_flight_scopes.is_empty() } // ── Goose-native steer withhold (side table) ────────────────────────── @@ -754,8 +922,9 @@ impl EventQueue { /// after `pool.send_steer` returns `Ok(())` and before any watcher task /// is spawned, so the withhold is established before `mark_complete` / /// any subsequent `flush_next` tick can run. - pub fn mark_native_steer_pending(&mut self, channel_id: Uuid, event_id: &str) -> bool { - let Some(q) = self.queues.get_mut(&channel_id) else { + pub fn mark_native_steer_pending(&mut self, scope: K, event_id: &str) -> bool { + let scope = scope.into_scope(); + let Some(q) = self.queues.get_mut(&scope) else { return false; }; let Some(pos) = q.iter().position(|qe| qe.event.id.to_hex() == event_id) else { @@ -765,10 +934,10 @@ impl EventQueue { .remove(pos) .expect("position came from iter so remove must succeed"); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(&scope); } self.withheld_native_steer - .entry(channel_id) + .entry(scope) .or_default() .push(qe); true @@ -784,8 +953,9 @@ impl EventQueue { /// /// Push-to-front matches the discipline of `requeue_preserve_timestamps` /// at line 453, preserving fairness across channels. - pub fn release_native_steer(&mut self, channel_id: Uuid, event_id: &str) { - let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) else { + pub fn release_native_steer(&mut self, scope: K, event_id: &str) { + let scope = scope.into_scope(); + let Some(entries) = self.withheld_native_steer.get_mut(&scope) else { return; }; let Some(pos) = entries @@ -796,21 +966,24 @@ impl EventQueue { }; let qe = entries.remove(pos); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } + let channel_id = scope.channel_id(); // Push to FRONT so original `received_at` keeps the event at the head - // of the channel's queue. Per-channel cap is enforced below in case + // of the scope's queue. Per-scope cap is enforced below in case // a flood of events arrived during the ack window. - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); queue.push_front(qe); - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "release_native_steer overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); } /// Drop a specific event by id from both the side table and the main @@ -819,17 +992,18 @@ impl EventQueue { /// Called on `SteerAck::Success` — the agent received the steer, so the /// event has been "delivered" via the non-cancelling path and must not /// be redelivered via normal dispatch. Idempotent across both stores. - pub fn remove_event(&mut self, channel_id: Uuid, event_id: &str) { - if let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) { + pub fn remove_event(&mut self, scope: K, event_id: &str) { + let scope = scope.into_scope(); + if let Some(entries) = self.withheld_native_steer.get_mut(&scope) { entries.retain(|qe| qe.event.id.to_hex() != event_id); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } } - if let Some(q) = self.queues.get_mut(&channel_id) { + if let Some(q) = self.queues.get_mut(&scope) { q.retain(|qe| qe.event.id.to_hex() != event_id); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(&scope); } } } @@ -847,25 +1021,29 @@ impl EventQueue { /// Iterates the stored entries in reverse so per-entry `push_front` /// composes to original-FIFO order at the queue front (same discipline /// as `requeue_preserve_timestamps` at line 453). - fn recover_withheld_for_expired_channel(&mut self, channel_id: Uuid) { - let Some(entries) = self.withheld_native_steer.remove(&channel_id) else { + fn recover_withheld_for_expired_scope(&mut self, scope: &SessionScope) { + let Some(entries) = self.withheld_native_steer.remove(scope) else { return; }; let n = entries.len(); - let queue = self.queues.entry(channel_id).or_default(); + let channel_id = scope.channel_id(); + let queue = self.queues.entry(scope.clone()).or_default(); for qe in entries.into_iter().rev() { queue.push_front(qe); } - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "withheld-steer recovery overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); tracing::warn!( channel_id = %channel_id, + scope = %scope.telemetry_label(), recovered = n, "in-flight expiry recovered withheld steer event(s) — \ steer ack never arrived; normal dispatch will deliver" @@ -894,10 +1072,10 @@ impl EventQueue { // Remove retry_counts for channels with no active throttle, no // queued events, AND no in-flight prompt — they completed their // retry cycle and are truly idle. - self.retry_counts.retain(|ch, _| { - self.retry_after.contains_key(ch) - || self.queues.get(ch).is_some_and(|q| !q.is_empty()) - || self.in_flight_channels.contains(ch) + self.retry_counts.retain(|scope, _| { + self.retry_after.contains_key(scope) + || self.queues.get(scope).is_some_and(|q| !q.is_empty()) + || self.in_flight_scopes.contains(scope) }); } } @@ -1446,7 +1624,7 @@ fn append_project_home(s: &mut String, channel_info: Option<&PromptChannelInfo>, )); } -/// Format a `` hints section based on event scope. +/// Format a `` section from the resolved session scope and turn routing. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see /// [`resolve_reply_anchor`]). In the thread/DM branches it threads ordinary @@ -1454,13 +1632,14 @@ fn append_project_home(s: &mut String, channel_info: Option<&PromptChannelInfo>, /// top-level mention whose reply should open a new thread rooted at the /// triggering event. fn format_context_hints( - channel_id: Uuid, + scope: &SessionScope, channel_info: Option<&PromptChannelInfo>, thread_tags: &ThreadTags, is_dm: bool, conversation_context_status: ConversationContextStatus, reply_anchor: Option<&str>, ) -> String { + let channel_id = scope.channel_id(); let channel_display = match channel_info { Some(ci) => format!("{} (#{channel_id})", ci.name), None => channel_id.to_string(), @@ -1499,6 +1678,7 @@ fn format_context_hints( }; let mut s = format!( "Scope: dm\n\ + Session scope: dm conversation\n\ Channel: {channel_display}\n\ {ctx_hint}" ); @@ -1515,7 +1695,10 @@ fn format_context_hints( } } crate::prompt_framing::semantic_section("context", &s) - } else if let Some(ref root) = thread_tags.root_event_id { + } else if let Some(root) = scope + .root_event_id() + .or(thread_tags.root_event_id.as_deref()) + { let ctx_hint = if complete_conversation_context { "Thread context included below." } else if has_conversation_context { @@ -1525,8 +1708,14 @@ fn format_context_hints( } else { "Use `buzz messages thread --channel --event ` to fetch thread context." }; + let session_scope = if scope.is_thread() { + "thread" + } else { + "channel" + }; let mut s = format!( "Scope: thread\n\ + Session scope: {session_scope}\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1539,12 +1728,17 @@ fn format_context_hints( } s.push_str(&format!("\n{ctx_hint}")); if let Some(event_id) = reply_anchor { - append_reply_instruction(&mut s, event_id); + if thread_tags.root_event_id.is_some() { + append_reply_instruction(&mut s, event_id); + } else { + append_new_thread_reply_instruction(&mut s, event_id); + } } crate::prompt_framing::semantic_section("context", &s) } else { let mut s = format!( "Scope: channel\n\ + Session scope: channel\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1821,10 +2015,9 @@ pub(crate) fn base_section(base_prompt: &str) -> String { /// For agents with `protocol_version >= 2`, base_prompt and system_prompt are /// delivered via the system role in `session/new` and omitted from this message. pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec { - // Scope is always derived from the LAST event in the batch — that's the - // one the agent is responding to. Thread/DM context is supplementary info - // included alongside, not a scope override. This prevents mixed batches - // (thread reply + later plain message) from being mislabeled as "thread". + // Session identity comes from admission (`batch.scope`). The last event + // determines reply routing only: a top-level trigger already owns a thread + // session under thread policy, even though it has no NIP-10 reply tags. let last_event = match batch.events.last() { Some(e) => e, None => { @@ -1881,7 +2074,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec SessionScope { + SessionScope::Conversation { channel_id } + } + + /// Build a QueuedEvent for the given channel (conversation scope). fn make_queued(channel_id: Uuid, content: &str) -> QueuedEvent { QueuedEvent { channel_id, + scope: conv(channel_id), event: make_event(content), received_at: Instant::now(), prompt_tag: "test".into(), @@ -2143,6 +2343,7 @@ mod tests { fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent { QueuedEvent { channel_id, + scope: conv(channel_id), event: make_event(content), received_at: Instant::now() - age, prompt_tag: "test".into(), @@ -2163,6 +2364,7 @@ mod tests { .unwrap(); QueuedEvent { channel_id, + scope: conv(channel_id), event, received_at: Instant::now(), prompt_tag: "test".into(), @@ -2174,7 +2376,145 @@ mod tests { } fn any_in_flight(q: &EventQueue) -> bool { - !q.in_flight_channels.is_empty() + !q.in_flight_scopes.is_empty() + } + + /// Thread scope within a channel, keyed by a synthetic 64-hex root. + fn thread(channel_id: Uuid, root: &str) -> SessionScope { + SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + /// Build a QueuedEvent for an explicit scope. + fn make_scoped(scope: SessionScope, content: &str) -> QueuedEvent { + QueuedEvent { + channel_id: scope.channel_id(), + scope, + event: make_event(content), + received_at: Instant::now(), + prompt_tag: "test".into(), + } + } + + // ── Step 2: scope partitioning ────────────────────────────────────────── + + #[test] + fn two_threads_in_one_channel_are_independent_partitions() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + let tb = thread(ch, &"b".repeat(64)); + q.push(make_scoped(ta.clone(), "thread-a")); + q.push(make_scoped(tb.clone(), "thread-b")); + + // First flush claims one thread; the other is still flushable because + // it is a distinct scope in the same channel. + let first = q.flush_next().expect("first batch"); + assert_eq!(first.channel_id, ch); + assert!(first.scope.is_thread()); + assert!(q.is_scope_in_flight(&first.scope)); + + // The sibling thread is NOT blocked by the first thread's in-flight turn. + let second = q.flush_next().expect("second batch"); + assert_eq!(second.channel_id, ch); + assert_ne!(first.scope, second.scope); + // Batches never mix scopes. + assert_eq!(first.events.len(), 1); + assert_eq!(second.events.len(), 1); + } + + #[test] + fn events_from_different_roots_never_share_a_batch() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + let tb = thread(ch, &"b".repeat(64)); + // Interleave pushes across the two thread scopes. + q.push(make_scoped(ta.clone(), "a1")); + q.push(make_scoped(tb.clone(), "b1")); + q.push(make_scoped(ta.clone(), "a2")); + q.push(make_scoped(tb.clone(), "b2")); + + let batch = q.flush_next().expect("batch"); + // Every event in the drained batch belongs to the single flushed scope. + let contents: Vec<&str> = batch + .events + .iter() + .map(|e| e.event.content.as_str()) + .collect(); + if batch.scope == ta { + assert_eq!(contents, vec!["a1", "a2"]); + } else { + assert_eq!(contents, vec!["b1", "b2"]); + } + } + + #[test] + fn in_flight_scope_blocks_only_that_scope_not_the_channel() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + q.push(make_scoped(ta.clone(), "a1")); + let _b = q.flush_next().expect("flush a"); + assert!(q.is_scope_in_flight(&ta)); + + // A new event on the SAME thread is blocked while in-flight (queue mode + // keeps it, but it is not re-flushable until mark_complete). + q.push(make_scoped(ta.clone(), "a2")); + assert!(q.flush_next().is_none()); + + // A new event on a DIFFERENT thread flushes immediately. + let tb = thread(ch, &"b".repeat(64)); + q.push(make_scoped(tb.clone(), "b1")); + let batch = q.flush_next().expect("sibling flushes"); + assert_eq!(batch.scope, tb); + + // Completing thread A unblocks its queued event. + q.mark_complete(ta.clone()); + let batch = q.flush_next().expect("a2 flushes after complete"); + assert_eq!(batch.scope, ta); + assert_eq!(batch.events[0].event.content, "a2"); + } + + #[test] + fn drain_channel_clears_every_child_thread_scope() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + q.push(make_scoped(thread(ch, &"a".repeat(64)), "a1")); + q.push(make_scoped(thread(ch, &"b".repeat(64)), "b1")); + q.push(make_scoped(conv(ch), "conv")); + q.push(make_scoped(thread(other, &"c".repeat(64)), "other")); + + let dropped = q.drain_channel(ch); + assert_eq!(dropped.len(), 3, "all three ch scopes drained"); + // The other channel's thread survives. + let batch = q.flush_next().expect("other channel still has work"); + assert_eq!(batch.channel_id, other); + } + + #[test] + fn aggregate_channel_cap_not_multiplied_by_threads() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + // Spread well over the aggregate cap across many thread scopes. + let total = MAX_PENDING_PER_CHANNEL + 250; + for i in 0..total { + let root = format!("{:064x}", i % 5); + q.push(make_scoped(thread(ch, &root), "x")); + } + let channel_total: usize = q + .queues + .iter() + .filter(|(s, _)| s.channel_id() == ch) + .map(|(_, v)| v.len()) + .sum(); + assert!( + channel_total <= MAX_PENDING_PER_CHANNEL, + "aggregate per-channel cap must bound all thread scopes combined, got {channel_total}" + ); } #[test] @@ -2359,6 +2699,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -2389,6 +2730,7 @@ mod tests { let ch = Uuid::new_v4(); FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("the new message"), prompt_tag: "@mention".into(), @@ -2520,6 +2862,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: make_event("new one"), @@ -2577,6 +2920,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: steering, prompt_tag: "@mention".into(), @@ -2628,7 +2972,7 @@ mod tests { queue.mark_complete(ch); // retry_after is set, so manually clear it for this test. - queue.retry_after.remove(&ch); + queue.retry_after.remove(&conv(ch)); // Should be able to flush again and get the same events in order. let batch2 = queue.flush_next().unwrap(); @@ -2669,7 +3013,7 @@ mod tests { assert!( queue .retry_after - .get(&ch) + .get(&conv(ch)) .is_some_and(|&t| t > Instant::now()), "requeue must have set a future backoff deadline" ); @@ -2748,6 +3092,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: e1, @@ -2788,6 +3133,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2811,6 +3157,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2843,6 +3190,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2873,6 +3221,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2900,6 +3249,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2924,6 +3274,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2982,6 +3333,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hello"), prompt_tag: "test".into(), @@ -3035,6 +3387,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3073,6 +3426,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3181,7 +3535,7 @@ mod tests { assert_eq!(batch_b.channel_id, ch_b); // Both in-flight. - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete A only. q.mark_complete(ch_a); @@ -3280,13 +3634,13 @@ mod tests { let _batch_a = q.flush_next().expect("flush A"); let _batch_b = q.flush_next().expect("flush B"); - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete only A. q.mark_complete(ch_a); - assert_eq!(q.in_flight_channels.len(), 1); - assert!(q.in_flight_channels.contains(&ch_b)); - assert!(!q.in_flight_channels.contains(&ch_a)); + assert_eq!(q.in_flight_scopes.len(), 1); + assert!(q.in_flight_scopes.contains(&conv(ch_b))); + assert!(!q.in_flight_scopes.contains(&conv(ch_a))); // B still in-flight. assert!(any_in_flight(&q)); @@ -3303,6 +3657,7 @@ mod tests { q.push(QueuedEvent { channel_id: ch, + scope: conv(ch), event: make_event("old-msg"), received_at: old_time, prompt_tag: "test".into(), @@ -3320,6 +3675,52 @@ mod tests { assert_eq!(batch2.events[0].received_at, original_received_at); } + #[test] + fn test_requeue_preserve_timestamps_round_trips_cancelled_carryover() { + // Regression: a held/exhausted merged batch (cancel + re-prompt) must + // not lose its original request. requeue_preserve_timestamps must + // restore events AND cancelled_events + cancel_reason so the next flush + // reconstructs the same merged batch. + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let scope = conv(ch); + let batch = FlushBatch { + channel_id: ch, + scope: scope.clone(), + events: vec![BatchEvent { + event: make_event("the follow-up"), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![BatchEvent { + event: make_event("the original request"), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancel_reason: Some(CancelReason::Interrupt), + }; + // Simulate the flushed-then-held state: scope is in-flight. + q.push(make_queued(ch, "placeholder")); + let _ = q.flush_next().expect("scope now in-flight"); + + q.requeue_preserve_timestamps(batch); + q.mark_complete(scope); + + let restored = q.flush_next().expect("merged batch re-flushes"); + assert_eq!(restored.events.len(), 1); + assert_eq!(restored.events[0].event.content, "the follow-up"); + assert_eq!( + restored.cancelled_events.len(), + 1, + "cancelled carryover (original request) must survive the requeue" + ); + assert_eq!( + restored.cancelled_events[0].event.content, + "the original request" + ); + assert_eq!(restored.cancel_reason, Some(CancelReason::Interrupt)); + } + #[test] fn test_requeue_preserve_timestamps_no_retry_after() { let mut q = EventQueue::new(DedupMode::Queue); @@ -3332,7 +3733,7 @@ mod tests { q.mark_complete(ch); // No retry_after — channel should be immediately flushable. - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_after.contains_key(&conv(ch))); assert!(q.flush_next().is_some()); } @@ -3438,7 +3839,7 @@ mod tests { // Manually expire the retry_after to simulate time passing. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); assert!( q.has_flushable_work(), "expired throttle should be flushable" @@ -3453,7 +3854,7 @@ mod tests { q.push(make_queued(ch, "poison")); for attempt in 1..=MAX_RETRIES { q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); assert!( q.requeue(batch).is_none(), @@ -3464,15 +3865,15 @@ mod tests { // The MAX_RETRIES+1'th failure dead-letters: batch is returned. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); let dead = q.requeue(batch).expect("should dead-letter"); assert_eq!(dead.channel_id, ch); assert_eq!(dead.events.len(), 1); q.mark_complete(ch); // Retry state is cleared so fresh traffic isn't throttled. - assert!(!q.retry_counts.contains_key(&ch)); - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_counts.contains_key(&conv(ch))); + assert!(!q.retry_after.contains_key(&conv(ch))); } #[test] @@ -3498,7 +3899,7 @@ mod tests { // After retry_after expires, ch should be flushable again. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); q.mark_complete(ch2); let batch3 = q .flush_next() @@ -3614,6 +4015,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3647,6 +4049,7 @@ mod tests { let event = make_event("hey"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3673,6 +4076,95 @@ mod tests { assert!(prompt.contains("Scope: dm")); } + #[test] + fn prompt_session_scope_matrix_preserves_turn_routing() { + use crate::scope::SessionPolicy; + + let channel_id = Uuid::new_v4(); + let top = make_event("start work"); + let root = top.id.to_hex(); + let reply = make_event_with_tags( + "continue work", + vec![vec![ + "e".into(), + root.to_uppercase(), + "".into(), + "reply".into(), + ]], + ); + for policy in [SessionPolicy::Channel, SessionPolicy::Thread] { + for is_dm in [false, true] { + for (event, is_reply) in [(&top, false), (&reply, true)] { + let batch = FlushBatch { + channel_id, + scope: SessionScope::derive(policy, channel_id, is_dm, event), + events: vec![BatchEvent { + event: event.clone(), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let ci = PromptChannelInfo { + name: "test".into(), + channel_type: if is_dm { "dm" } else { "stream" }.into(), + description: None, + project: None, + }; + // Session scope must remain visible on every turn, even + // after standing context was sent or via modern ACP. + for modern in [false, true] { + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: modern, + standing_context_sent: true, + ..Default::default() + }, + ) + .join("\n\n"); + if is_dm { + assert!(prompt.contains("Session scope: dm conversation")); + assert!(prompt.contains("Scope: dm")); + } else if policy == SessionPolicy::Thread { + assert!(prompt.contains("Session scope: thread")); + assert!(prompt.contains("Scope: thread")); + assert!(prompt.contains(&format!("Thread root: {root}"))); + assert!(prompt.contains("buzz messages thread")); + assert!(!prompt.contains("buzz messages get")); + } else { + assert!(prompt.contains("Session scope: channel")); + assert!(prompt.contains(if is_reply { + "Scope: thread" + } else { + "Scope: channel" + })); + } + assert_eq!( + prompt.contains("This is a new top-level message"), + !is_dm && !is_reply + ); + if !is_dm || is_reply { + let anchor = if is_dm { + reply.id.to_hex() + } else if is_reply { + root.to_uppercase() + } else { + root.clone() + }; + assert!(prompt.contains(&format!("--reply-to {anchor}"))); + } else { + assert!(!prompt.contains("--reply-to")); + assert!(prompt.contains("buzz messages get")); + } + } + } + } + } + } + #[test] fn test_format_prompt_thread_scope() { let ch = Uuid::new_v4(); @@ -3687,6 +4179,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3713,6 +4206,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3845,6 +4339,7 @@ mod tests { let mixed_batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ reply("older reply in thread A", &root_a), reply("newer reply in thread B", &root_b), @@ -3869,6 +4364,7 @@ mod tests { let same_thread_batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ reply("older reply in thread B", &root_b), reply("newer reply in thread B", &root_b), @@ -3896,6 +4392,7 @@ mod tests { let event = make_event("ok do that"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3952,6 +4449,7 @@ mod tests { let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4161,6 +4659,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -4231,6 +4730,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4264,6 +4764,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("follow up"), prompt_tag: "dm".into(), @@ -4314,6 +4815,7 @@ mod tests { let event = make_event("hey there"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -4356,6 +4858,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4380,6 +4883,7 @@ mod tests { let npub = event.pubkey.to_bech32().unwrap(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4403,6 +4907,7 @@ mod tests { let event = make_event_with_tags("hello", vec![vec!["h".into(), ch.to_string()]]); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4561,25 +5066,25 @@ mod tests { let batch = q.flush_next().unwrap(); q.requeue(batch); q.mark_complete(ch); - assert!(q.retry_after.contains_key(&ch)); - assert!(q.retry_counts.contains_key(&ch)); + assert!(q.retry_after.contains_key(&conv(ch))); + assert!(q.retry_counts.contains_key(&conv(ch))); // The requeued event is back in the queue. Flush it again so the // queue is empty (simulating a successful retry dispatch). // We need to wait for retry_after to expire first. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Now mark_complete with no active throttle — clears retry_counts. q.mark_complete(ch); - assert!(!q.retry_counts.contains_key(&ch)); + assert!(!q.retry_counts.contains_key(&conv(ch))); // Re-create the orphan scenario: manually insert stale retry_counts // with no queue, no throttle, and no in-flight. - q.retry_counts.insert(ch, 3); + q.retry_counts.insert(conv(ch), 3); q.compact_expired_state(); assert!( - !q.retry_counts.contains_key(&ch), + !q.retry_counts.contains_key(&conv(ch)), "orphaned retry_counts should be removed" ); } @@ -4597,17 +5102,17 @@ mod tests { // Expire the throttle so the requeued event can be flushed. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Channel is now in-flight with empty queue and expired throttle. - assert!(q.in_flight_channels.contains(&ch)); - assert!(q.queues.get(&ch).is_none_or(|q| q.is_empty())); + assert!(q.in_flight_scopes.contains(&conv(ch))); + assert!(q.queues.get(&conv(ch)).is_none_or(|q| q.is_empty())); // compact must NOT remove retry_counts — the in-flight attempt // may fail and requeue, which needs the existing count. q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&conv(ch)), "retry_counts must survive while channel is in-flight" ); } @@ -4619,11 +5124,11 @@ mod tests { // Manually set up: retry_counts exists, queue is non-empty, no throttle. q.push(make_queued(ch, "msg1")); - q.retry_counts.insert(ch, 2); + q.retry_counts.insert(conv(ch), 2); q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&conv(ch)), "retry_counts should survive when queue is non-empty" ); } @@ -4830,6 +5335,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4872,6 +5378,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4908,6 +5415,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4937,6 +5445,7 @@ mod tests { let event = make_event("hey there"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4981,6 +5490,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -5017,6 +5527,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -5052,6 +5563,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: plain, @@ -5089,6 +5601,7 @@ mod tests { let plain_id = plain.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: threaded, @@ -5120,8 +5633,10 @@ mod tests { /// Build a single-event FlushBatch with the given content. fn make_single_batch(content: &str) -> FlushBatch { + let channel_id = Uuid::new_v4(); FlushBatch { - channel_id: Uuid::new_v4(), + channel_id, + scope: conv(channel_id), events: vec![BatchEvent { event: make_event(content), prompt_tag: "test".into(), @@ -5256,7 +5771,10 @@ mod tests { "withheld-only channel must not register as flushable work" ); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(1)); + assert_eq!( + q.withheld_native_steer.get(&conv(ch)).map(|v| v.len()), + Some(1) + ); } /// Earlier events on the same channel must flush normally during the @@ -5324,9 +5842,9 @@ mod tests { // Simulate a prompt in flight for `ch`, then withhold the queued // event for an in-flight goose-native steer. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), Instant::now()); + q.in_flight_batch_sizes.insert(conv(ch), 1); assert!(q.mark_native_steer_pending(ch, &event_id)); // Force the in-flight deadline to be in the past, simulating the @@ -5334,7 +5852,7 @@ mod tests { // for `in_flight_deadline` to elapse. Same expiry-simulation // trick used by `test_retry_throttle_blocks_requeue_channel`. q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); // `has_flushable_work` runs the expiry block first; it must recover // the withheld event so the channel registers as flushable. @@ -5386,20 +5904,23 @@ mod tests { assert!(q.mark_native_steer_pending(ch, &e2_id)); assert!(q.mark_native_steer_pending(ch, &e3_id)); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(3)); + assert_eq!( + q.withheld_native_steer.get(&conv(ch)).map(|v| v.len()), + Some(3) + ); // Trigger expiry → bulk-release path. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.in_flight_batch_sizes.insert(ch, 3); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); + q.in_flight_batch_sizes.insert(conv(ch), 3); assert!(q.has_flushable_work()); // After recovery, the queue front-to-back order must match the // original FIFO: e1, e2, e3. let recovered: Vec = q .queues - .get(&ch) + .get(&conv(ch)) .expect("queue restored") .iter() .map(|qe| qe.event.id.to_hex()) @@ -5416,6 +5937,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -5445,6 +5967,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -5473,6 +5996,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -5521,11 +6045,11 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let old_deadline = Instant::now() + Duration::from_secs(100); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, old_deadline); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), old_deadline); q.extend_in_flight_deadline(ch, 7200); - let new = *q.in_flight_deadlines.get(&ch).unwrap(); + let new = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert!( new > old_deadline, "extended deadline must be past the original" @@ -5537,11 +6061,11 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let far_future = Instant::now() + Duration::from_secs(999_999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, far_future); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), far_future); q.extend_in_flight_deadline(ch, 7200); - let after = *q.in_flight_deadlines.get(&ch).unwrap(); + let after = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert_eq!(after, far_future, "deadline must never move backward"); } @@ -5549,17 +6073,17 @@ mod tests { fn extend_in_flight_deadline_noop_after_mark_complete() { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(100)); + q.in_flight_batch_sizes.insert(conv(ch), 1); q.mark_complete(ch); - assert!(!q.in_flight_deadlines.contains_key(&ch)); + assert!(!q.in_flight_deadlines.contains_key(&conv(ch))); q.extend_in_flight_deadline(ch, 7200); assert!( - !q.in_flight_deadlines.contains_key(&ch), + !q.in_flight_deadlines.contains_key(&conv(ch)), "extend after mark_complete must not resurrect a deadline" ); } @@ -5569,17 +6093,17 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let extended = Instant::now() + Duration::from_secs(9999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, extended); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), extended); q.compact_expired_state(); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines.contains_key(&conv(ch)), "compaction must not touch in-flight deadlines" ); assert_eq!( - *q.in_flight_deadlines.get(&ch).unwrap(), + *q.in_flight_deadlines.get(&conv(ch)).unwrap(), extended, "compaction must leave extended deadline intact" ); @@ -5598,9 +6122,9 @@ mod tests { // Insert the channel as in-flight with a deadline already in the past // (Instant::now() — by the time flush_next runs, now >= deadline). - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), Instant::now()); + q.in_flight_batch_sizes.insert(conv(ch), 1); // Also push an event so flush_next has something to do after expiry. q.push(make_queued(ch, "after-expiry")); @@ -5626,10 +6150,10 @@ mod tests { let ch = Uuid::new_v4(); // Put the channel in-flight with an extended deadline far in the future. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(conv(ch), 1); // Push an event for another channel so flush_next has work to do. let ch2 = Uuid::new_v4(); @@ -5643,11 +6167,11 @@ mod tests { // ch must still be in-flight — the extended deadline did not expire. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must remain in-flight after flush_next with an extended deadline" ); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines.contains_key(&conv(ch)), "in-flight deadline for ch must not be removed by flush_next" ); } @@ -5664,10 +6188,10 @@ mod tests { let ch = Uuid::new_v4(); // In-flight channel with extended (far-future) deadline. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(conv(ch), 1); // No other channels — nothing flushable. assert!( @@ -5675,7 +6199,7 @@ mod tests { "has_flushable_work must return false when the only channel is in-flight with extended deadline" ); assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must remain in-flight after has_flushable_work with extended deadline" ); @@ -5688,7 +6212,7 @@ mod tests { ); // ch still in-flight and not expired. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must still be in-flight after has_flushable_work finds ch2 work" ); } @@ -5703,15 +6227,15 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); + .insert(conv(ch), Instant::now() + Duration::from_secs(100)); q.extend_in_flight_deadline(ch, 7200); - let after_first = *q.in_flight_deadlines.get(&ch).unwrap(); + let after_first = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); q.extend_in_flight_deadline(ch, 7200); - let after_second = *q.in_flight_deadlines.get(&ch).unwrap(); + let after_second = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert!( after_second >= after_first, @@ -5934,6 +6458,7 @@ mod tests { fn description_batch(ch: Uuid, event: Event) -> FlushBatch { FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 6188e57a11d..e4e41b4660d 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -277,6 +277,75 @@ fn unix_now_secs() -> u64 { } impl RestClient { + /// Fetch the relay's stable signing identity from its NIP-11 document. + /// + /// Relay-authored workflow attribution is trusted only when the event signer + /// matches this key. Missing, malformed, or unavailable identity data fails + /// closed by returning an error/`None` to the caller. NIP-11 is standardized + /// at the relay root; `/info` remains a compatibility fallback for relays + /// that expose the document through Buzz's explicit alias. + pub async fn relay_self(&self) -> Result, RelayError> { + let mut failures = Vec::new(); + let mut saw_document_without_self = false; + + for path in ["/", "/info"] { + let url = format!("{}{path}", self.base_url); + let response = match self + .http + .get(&url) + .header(reqwest::header::ACCEPT, "application/nostr+json") + .send() + .await + { + Ok(response) => response, + Err(error) => { + failures.push(format!("GET {path} failed: {error}")); + continue; + } + }; + + if !response.status().is_success() { + failures.push(format!("GET {path} returned HTTP {}", response.status())); + continue; + } + + let document: serde_json::Value = match response.json().await { + Ok(document) => document, + Err(error) => { + failures.push(format!("GET {path} returned invalid NIP-11 JSON: {error}")); + continue; + } + }; + let Some(relay_self) = document.get("self") else { + saw_document_without_self = true; + continue; + }; + let Some(relay_self) = relay_self.as_str() else { + failures.push(format!("GET {path} returned a non-string NIP-11 self key")); + continue; + }; + let relay_self = match nostr::PublicKey::from_hex(relay_self) { + Ok(pubkey) => pubkey.to_hex(), + Err(error) => { + failures.push(format!( + "GET {path} returned an invalid NIP-11 self key: {error}" + )); + continue; + } + }; + return Ok(Some(relay_self)); + } + + if saw_document_without_self { + Ok(None) + } else { + Err(RelayError::Http(format!( + "failed to fetch a usable NIP-11 document: {}", + failures.join("; ") + ))) + } + } + /// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body. /// /// Returns the `Authorization: Nostr ` header value (without the @@ -515,6 +584,10 @@ impl RestClient { /// Events the harness cares about. #[derive(Debug, Clone)] pub struct BuzzEvent { + /// Which authenticated relay connection delivered this event. Generation 0 + /// is the initial connection; each successful reconnect increments it + /// before any buffered or live event from that connection is forwarded. + pub connection_generation: u64, /// Which channel this event belongs to. pub channel_id: Uuid, /// The underlying Nostr event. @@ -1140,6 +1213,10 @@ struct BgState { /// A single failed channel REQ is parked here instead of aborting the whole /// reconnect. Drained by the main loop. Flushed on each reconnect attempt. resubscribe_retry: HashSet, + /// Current authenticated WebSocket generation. Incremented immediately + /// after each successful reconnect handshake, before buffered or live + /// events from the new connection are forwarded. + connection_generation: u64, /// Current position in the exponential backoff ladder. /// /// Persisted across calls to `wait_for_reconnect` so a flapping link stays at @@ -1171,6 +1248,7 @@ impl BgState { observer_in_flight: VecDeque::new(), gated_observer_dropped: 0, resubscribe_retry: HashSet::new(), + connection_generation: 0, backoff_step: 0, } } @@ -1292,6 +1370,40 @@ impl BgState { while let Some(event) = self.observer_in_flight.pop_back() { self.gated_observer_pending.push_front(event); } + self.trim_gated_observer_pending(); + } + + /// Re-park a frame the relay explicitly refused, ahead of frames parked + /// after the gate armed. + /// + /// An `OK(id, false, …)` names the refused frame, so only that frame is + /// retried — frames still awaiting their own verdict stay in the + /// acknowledgment window. This is the correlated counterpart to + /// [`Self::requeue_observer_in_flight`], which must retry everything + /// because a NOTICE identifies nothing. + fn requeue_rejected_observer_frame(&mut self, event_id: &str) { + let Some(index) = self + .observer_in_flight + .iter() + .position(|event| event.id.to_hex() == event_id) + else { + return; + }; + if let Some(event) = self.observer_in_flight.remove(index) { + if self.gated_observer_pending.len() >= GATED_OBSERVER_QUEUE_CAP { + self.gated_observer_pending.pop_front(); + self.gated_observer_dropped += 1; + warn!( + dropped_total = self.gated_observer_dropped, + "gated observer queue full — dropped oldest parked frame for refused retry" + ); + } + self.gated_observer_pending.push_front(event); + } + } + + /// Enforce the parked-queue bound, counting evictions so loss stays visible. + fn trim_gated_observer_pending(&mut self) { while self.gated_observer_pending.len() > GATED_OBSERVER_QUEUE_CAP { self.gated_observer_pending.pop_front(); self.gated_observer_dropped += 1; @@ -2189,6 +2301,7 @@ async fn handle_ws_message( } let ts = event.created_at.as_secs(); let buzz_event = BuzzEvent { + connection_generation: state.connection_generation, channel_id: channel_uuid, event: *event, }; @@ -2230,6 +2343,7 @@ async fn handle_ws_message( let event_id_hex = event.id.to_hex(); if state.record_event(channel_id, &event) { let buzz_event = BuzzEvent { + connection_generation: state.connection_generation, channel_id, event: *event, }; @@ -2282,7 +2396,10 @@ async fn handle_ws_message( RelayMessage::Notice { message } => { // Fix 4: NOTICE at warn level. tracing::warn!("relay NOTICE: {message}"); - // The relay sends NOTICE for rate-limited EVENT/COUNT frames. + // NOTICE now carries only connection-scoped refusals: an + // EVENT is refused via OK and a REQ/COUNT via CLOSED. A + // NOTICE names nothing, so every unacknowledged observer + // write must be retried. if message.starts_with("rate-limited:") { let secs = parse_rate_limit_retry_secs(&message).unwrap_or(0); let deadline = state.set_rate_limit_gate(secs); @@ -2450,6 +2567,25 @@ async fn handle_ws_message( warn!("mid-session AUTH rejected (event {event_id}): {message} — triggering reconnect"); return false; } + // A refused EVENT is acknowledged on its own channel, so the + // backoff must arm here — not only in the NOTICE arm. Without + // this the harness would publish straight back into the same + // quota it was just refused on. + if !accepted && message.starts_with("rate-limited:") { + let secs = parse_rate_limit_retry_secs(&message).unwrap_or(0); + let deadline = state.set_rate_limit_gate(secs); + // The OK names the refused frame, so re-park only that + // one rather than every unacknowledged frame. + state.requeue_rejected_observer_frame(&event_id); + warn!( + "rate-limit gate armed via OK for event {event_id} until ~{:.1}s from now", + deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or_default() + .as_secs_f64() + ); + return true; + } state.acknowledge_observer_frame(&event_id); debug!("OK for event {event_id}: accepted={accepted} message={message}"); } @@ -3013,6 +3149,7 @@ async fn try_autonomous_reconnect( match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; + state.connection_generation = state.connection_generation.saturating_add(1); info!("autonomous reconnect succeeded (attempt {})", attempt + 1); let handshake_ok = process_handshake_buffer( ws, @@ -3151,6 +3288,7 @@ async fn wait_for_reconnect( match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; + state.connection_generation = state.connection_generation.saturating_add(1); info!("relay reconnected to {relay_url}"); let handshake_ok = process_handshake_buffer( ws, @@ -4084,6 +4222,147 @@ async fn wait_for_any_ok( mod tests { use super::*; + async fn nip11_test_client( + responses: HashMap, + ) -> ( + RestClient, + std::sync::Arc>>, + tokio::task::JoinHandle<()>, + ) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind NIP-11 test server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("test server address") + ); + let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let server_requests = requests.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 8192]; + let bytes_read = socket.read(&mut request).await.unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..bytes_read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + let has_nip11_accept = request + .lines() + .any(|line| line.eq_ignore_ascii_case("accept: application/nostr+json")); + server_requests + .lock() + .expect("lock recorded NIP-11 requests") + .push((path.clone(), has_nip11_accept)); + + let (status, body) = responses + .get(&path) + .cloned() + .unwrap_or_else(|| (404, "not found".into())); + let reason = if status == 200 { "OK" } else { "Not Found" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let client = RestClient { + http: reqwest::Client::new(), + base_url, + keys: Keys::generate(), + auth_tag_json: None, + }; + (client, requests, server) + } + + #[tokio::test] + async fn relay_self_reads_and_normalizes_standard_root_document() { + let uppercase = "AB".repeat(32); + let responses = HashMap::from([ + ( + "/".to_string(), + (200, serde_json::json!({ "self": uppercase }).to_string()), + ), + ( + "/info".to_string(), + ( + 200, + serde_json::json!({ "self": "cd".repeat(32) }).to_string(), + ), + ), + ]); + let (client, requests, server) = nip11_test_client(responses).await; + + assert_eq!( + client.relay_self().await.expect("fetch relay self"), + Some("ab".repeat(32)) + ); + assert_eq!( + *requests.lock().expect("lock recorded requests"), + vec![("/".to_string(), true)], + "the standard root document should be preferred and request NIP-11 JSON" + ); + server.abort(); + } + + #[tokio::test] + async fn relay_self_falls_back_to_info_alias() { + let responses = HashMap::from([ + ("/".to_string(), (404, "not found".into())), + ( + "/info".to_string(), + ( + 200, + serde_json::json!({ "self": "cd".repeat(32) }).to_string(), + ), + ), + ]); + let (client, requests, server) = nip11_test_client(responses).await; + + assert_eq!( + client.relay_self().await.expect("fetch relay self"), + Some("cd".repeat(32)) + ); + assert_eq!( + *requests.lock().expect("lock recorded requests"), + vec![("/".to_string(), true), ("/info".to_string(), true)] + ); + server.abort(); + } + + #[tokio::test] + async fn relay_self_rejects_malformed_identity_at_both_endpoints() { + let responses = HashMap::from([ + ( + "/".to_string(), + ( + 200, + serde_json::json!({ "self": "not-a-pubkey" }).to_string(), + ), + ), + ( + "/info".to_string(), + (200, serde_json::json!({ "self": 42 }).to_string()), + ), + ]); + let (client, _requests, server) = nip11_test_client(responses).await; + + let error = client + .relay_self() + .await + .expect_err("malformed relay identities must fail closed"); + assert!(error + .to_string() + .contains("failed to fetch a usable NIP-11 document")); + server.abort(); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( @@ -5913,6 +6192,151 @@ mod tests { ); } + /// A rate-limited `OK(id, false, …)` must arm the backoff gate and re-park + /// the refused frame, driven through the real frame dispatcher. + /// + /// This is the buzz-acp side of the relay's rejection-correlation change: + /// a refused EVENT is now acknowledged on its own channel instead of via + /// NOTICE. Reverting either the gate arming or the requeue in the `Ok` arm + /// must fail this test. + #[tokio::test] + async fn rate_limited_ok_arms_gate_and_reparks_refused_observer_frame() { + let (mut client, _server) = test_ws_pair().await; + let (event_tx, _event_rx) = mpsc::channel::>(4); + let (observer_control_tx, _observer_control_rx) = mpsc::channel::(4); + let keys = Keys::generate(); + let mut state = BgState::new(); + + let refused = make_observer_frame(&keys); + let still_pending = make_observer_frame(&keys); + state.track_observer_in_flight(Box::new(refused.clone())); + state.track_observer_in_flight(Box::new(still_pending.clone())); + assert!( + state.check_rate_gate().is_none(), + "gate must start disarmed" + ); + + let frame = json!([ + "OK", + refused.id.to_hex(), + false, + "rate-limited: retry in 5s" + ]); + let should_continue = handle_ws_message( + Message::Text(frame.to_string().into()), + &mut client, + &event_tx, + &observer_control_tx, + &mut state, + &keys, + "wss://relay.test", + "agent-pubkey", + None, + ) + .await; + + assert!(should_continue, "a rate-limited OK must keep the socket"); + assert!( + state.check_rate_gate().is_some(), + "a rate-limited OK must arm the backoff gate, or the harness \ + republishes straight into the same quota" + ); + let parked: Vec<_> = state + .gated_observer_pending + .iter() + .map(|event| event.id) + .collect(); + assert_eq!( + parked, + [refused.id], + "the refused frame must be re-parked for redelivery, not dropped" + ); + let in_flight: Vec<_> = state + .observer_in_flight + .iter() + .map(|event| event.id) + .collect(); + assert_eq!( + in_flight, + [still_pending.id], + "frames still awaiting their own verdict must stay in flight" + ); + } + + #[test] + fn rejected_observer_frame_displaces_oldest_parked_frame_at_capacity() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let refused = make_observer_frame(&keys); + state.track_observer_in_flight(Box::new(refused.clone())); + + let oldest = make_observer_frame(&keys); + state.park_gated_observer_frame(Box::new(oldest.clone())); + let mut survivors = Vec::with_capacity(GATED_OBSERVER_QUEUE_CAP - 1); + for _ in 1..GATED_OBSERVER_QUEUE_CAP { + let event = make_observer_frame(&keys); + survivors.push(event.id); + state.park_gated_observer_frame(Box::new(event)); + } + + state.requeue_rejected_observer_frame(&refused.id.to_hex()); + + let parked: Vec<_> = state + .gated_observer_pending + .iter() + .map(|event| event.id) + .collect(); + assert_eq!(parked.len(), GATED_OBSERVER_QUEUE_CAP); + assert_eq!(parked.first(), Some(&refused.id)); + assert_eq!(&parked[1..], survivors.as_slice()); + assert!(!parked.contains(&oldest.id)); + assert_eq!(state.gated_observer_dropped, 1); + assert!(state.observer_in_flight.is_empty()); + } + + /// A non-rate-limit refusal is terminal: retrying would be refused + /// identically, so the frame is retired rather than re-parked, and the + /// backoff gate stays disarmed. + #[tokio::test] + async fn non_rate_limited_ok_rejection_retires_frame_without_arming_gate() { + let (mut client, _server) = test_ws_pair().await; + let (event_tx, _event_rx) = mpsc::channel::>(4); + let (observer_control_tx, _observer_control_rx) = mpsc::channel::(4); + let keys = Keys::generate(); + let mut state = BgState::new(); + + let refused = make_observer_frame(&keys); + state.track_observer_in_flight(Box::new(refused.clone())); + + let frame = json!(["OK", refused.id.to_hex(), false, "invalid: bad signature"]); + let should_continue = handle_ws_message( + Message::Text(frame.to_string().into()), + &mut client, + &event_tx, + &observer_control_tx, + &mut state, + &keys, + "wss://relay.test", + "agent-pubkey", + None, + ) + .await; + + assert!(should_continue, "a rejected event must not drop the socket"); + assert!( + state.check_rate_gate().is_none(), + "only a rate-limit refusal arms the backoff gate" + ); + assert!( + state.gated_observer_pending.is_empty(), + "a permanently refused frame must not be requeued into a retry loop" + ); + assert!( + state.observer_in_flight.is_empty(), + "a permanently refused frame must be retired from the window" + ); + } + /// Build a signed observer telemetry frame (kind 24200) for gate tests. fn make_observer_frame(keys: &Keys) -> Event { let recipient = Keys::generate(); diff --git a/crates/buzz-acp/src/scope.rs b/crates/buzz-acp/src/scope.rs new file mode 100644 index 00000000000..d32207e5055 --- /dev/null +++ b/crates/buzz-acp/src/scope.rs @@ -0,0 +1,405 @@ +//! Session scoping for ACP. +//! +//! A [`SessionScope`] is the single hashable key that identifies an ACP +//! provider session and its conversational-context boundary. It is derived +//! **once**, when an eligible event is admitted, from the operator +//! [`SessionPolicy`], whether the channel is a DM, and the event's NIP-10 +//! thread tags. Later code must never re-infer scope from the last event in a +//! batch — it carries the resolved scope instead. +//! +//! Policy matrix (see the "Make ACP sessions thread-scoped" ticket): +//! +//! | Surface | Scope | +//! | ----------------------------------- | --------------------------------------- | +//! | New top-level channel mention | `Thread(channel_id, triggering_event)` | +//! | Reply in a channel thread | `Thread(channel_id, canonical_root)` | +//! | Repeated mention in the same thread | reuse that thread scope | +//! | Direct message | `Conversation(channel_id)` | +//! +//! Under [`SessionPolicy::Channel`] (the current default / rollback path) every +//! surface collapses to `Conversation(channel_id)`, preserving today's +//! channel-keyed behavior exactly. + +use nostr::Event; +use uuid::Uuid; + +use crate::queue::parse_thread_tags; + +/// Operator policy controlling how ACP provider sessions are scoped. +/// +/// Selected via `--session-policy` / `BUZZ_ACP_SESSION_POLICY`. Defaults to +/// [`Channel`](SessionPolicy::Channel) so the feature ships dark and can be +/// canaried, then flipped, then rolled back without code changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum SessionPolicy { + /// Legacy behavior: one provider session per channel. Every event in a + /// channel shares a `Conversation(channel_id)` scope. + #[default] + Channel, + /// Thread-scoped: each canonical channel thread gets an isolated provider + /// session. DMs remain conversation-scoped. + Thread, +} + +impl SessionPolicy { + /// Append only the configured session model to the shared base instructions. + /// The resulting base is reused by modern and legacy ACP standing context. + pub(crate) fn append_session_model(self, base_prompt: &str) -> String { + let session_model = match self { + Self::Channel => include_str!("session_model_channel.md"), + Self::Thread => include_str!("session_model_thread.md"), + }; + format!("{}\n\n{}", base_prompt.trim_end(), session_model.trim_end()) + } +} + +impl std::fmt::Display for SessionPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Channel => f.write_str("channel"), + Self::Thread => f.write_str("thread"), + } + } +} + +/// A hashable ACP execution and conversational-context scope. +/// +/// This is the canonical key for provider sessions, queue partitions, in-flight +/// tracking, and context gathering. The channel remains the authorization and +/// collaboration boundary; the scope is the default *execution* boundary. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SessionScope { + /// The whole channel is one session. Used for DMs always, and for every + /// channel event under [`SessionPolicy::Channel`]. + Conversation { channel_id: Uuid }, + /// A single canonical thread within a channel, keyed by its root event id + /// (64-char lowercase hex). + Thread { + channel_id: Uuid, + root_event_id: String, + }, +} + +impl SessionScope { + /// The channel this scope belongs to. Always available — the channel is the + /// authorization boundary regardless of scope variant. + pub fn channel_id(&self) -> Uuid { + match self { + Self::Conversation { channel_id } => *channel_id, + Self::Thread { channel_id, .. } => *channel_id, + } + } + + /// The canonical thread-root event id for a [`Thread`](Self::Thread) scope, + /// or `None` for a conversation scope. + pub fn root_event_id(&self) -> Option<&str> { + match self { + Self::Conversation { .. } => None, + Self::Thread { root_event_id, .. } => Some(root_event_id), + } + } + + /// True when this scope is thread-scoped (not conversation-scoped). + pub fn is_thread(&self) -> bool { + matches!(self, Self::Thread { .. }) + } + + /// Derive the scope for an admitted event. + /// + /// Resolution order: + /// 1. DMs are always [`Conversation`](Self::Conversation) — the ticket keeps + /// direct messages conversation-scoped regardless of policy. + /// 2. Under [`SessionPolicy::Channel`], every channel event is + /// conversation-scoped (legacy / rollback behavior). + /// 3. Under [`SessionPolicy::Thread`], a channel event with a NIP-10 root + /// tag scopes to that canonical root; a top-level mention (no thread + /// tags) opens a new thread rooted at the triggering event id. + /// + /// Thread roots are resolved with [`parse_thread_tags`], i.e. Buzz's shared + /// [`buzz_core::nip10`] canonical-root rules — a malformed marker id is + /// ignored (treated as top-level), and a lone `root` marker with no `reply` + /// is top-level, matching relay ingest. + /// + /// The root id is normalized to lowercase before it becomes the scope key. + /// The shared NIP-10 parser accepts and preserves uppercase ASCII hex + /// (`is_ascii_hexdigit`), but the relay decodes event ids to bytes on + /// ingest, so `AB…` and `ab…` name the *same* thread. Without normalization + /// those equivalent spellings would hash to different `Thread` keys and + /// split one relay thread across two ACP sessions (queue state, provider + /// sessions, affinity, delivery ledgers). `nostr::EventId::to_hex()` is + /// already lowercase, so the top-level path is unaffected. + pub fn derive(policy: SessionPolicy, channel_id: Uuid, is_dm: bool, event: &Event) -> Self { + if is_dm || policy == SessionPolicy::Channel { + return Self::Conversation { channel_id }; + } + + let root_event_id = match parse_thread_tags(event).root_event_id { + Some(root) => root, + None => event.id.to_hex(), + }; + Self::Thread { + channel_id, + root_event_id: root_event_id.to_ascii_lowercase(), + } + } + + /// A compact, log-friendly label for telemetry (e.g. `conversation` or + /// `thread:`), never leaking full ids into high-cardinality fields. + pub fn telemetry_label(&self) -> String { + match self { + Self::Conversation { .. } => "conversation".to_string(), + Self::Thread { root_event_id, .. } => { + let short: String = root_event_id.chars().take(8).collect(); + format!("thread:{short}") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + /// Build a signed event with the given NIP-10 `e`/`p` tags. + fn event_with_tags(tags: Vec>) -> Event { + let keys = Keys::generate(); + let tags: Vec = tags + .into_iter() + .map(|t| nostr::Tag::parse(t).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::Custom(9), "hello") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + fn plain_event() -> Event { + event_with_tags(vec![]) + } + + #[test] + fn session_model_is_appended_once_and_matches_policy() { + let base = include_str!("base_prompt.md"); + assert!(!base.contains("## Session Model")); + for policy in [SessionPolicy::Channel, SessionPolicy::Thread] { + let prompt = policy.append_session_model(base); + assert!(prompt.starts_with(base.trim_end())); + assert_eq!(prompt.matches("## Session Model").count(), 1); + assert!(prompt.ends_with("assume the owning session has it handled.")); + assert!(prompt.contains("DMs stay one conversation")); + assert!(prompt.contains( + "core memory, your workspace on disk, relay access, and channel authorization" + )); + assert!(prompt.contains("leave execution with the owning session")); + match policy { + SessionPolicy::Channel => { + assert!(prompt.contains("one per-channel session")); + assert!(!prompt.contains("each thread gets its own")); + assert!(!prompt.contains("sibling channel thread")); + } + SessionPolicy::Thread => { + assert!(prompt.contains("each thread gets its own")); + assert!(prompt.contains("sibling channel thread")); + assert!(!prompt.contains("one per-channel session")); + } + } + } + } + + #[test] + fn dm_is_always_conversation_scoped_under_thread_policy() { + let ch = Uuid::new_v4(); + // Even a DM with a reply tag stays conversation-scoped. + let root = "a".repeat(64); + let reply = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "b".repeat(64), String::new(), "reply".into()], + ]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, true, &reply); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + } + + #[test] + fn channel_policy_collapses_everything_to_conversation() { + let ch = Uuid::new_v4(); + let root = "a".repeat(64); + let reply = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "b".repeat(64), String::new(), "reply".into()], + ]); + // A threaded reply under Channel policy is still conversation-scoped. + let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &reply); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + // As is a top-level mention. + let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &plain_event()); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + } + + #[test] + fn top_level_mention_opens_thread_rooted_at_trigger() { + let ch = Uuid::new_v4(); + let ev = plain_event(); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn direct_reply_to_root_scopes_to_that_root() { + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + // A single `e` tag carrying only a `root` marker. + let ev = event_with_tags(vec![vec![ + "e".into(), + root.clone(), + String::new(), + "root".into(), + ]]); + // NIP-10: lone `root` with no `reply` is top-level per ingest rules, so + // this yields a top-level scope rooted at the trigger, not `root`. + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn nested_reply_scopes_to_canonical_root_not_parent() { + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + let parent = "d".repeat(64); + let ev = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), parent.clone(), String::new(), "reply".into()], + ]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + // Scope keys on the canonical ROOT, never the immediate parent. + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: root, + } + ); + } + + #[test] + fn repeated_replies_in_same_thread_share_scope() { + let ch = Uuid::new_v4(); + let root = "e".repeat(64); + let mk_reply = || { + event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "f".repeat(64), String::new(), "reply".into()], + ]) + }; + let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply()); + let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply()); + assert_eq!(a, b, "same-root replies must reuse the same thread scope"); + } + + #[test] + fn different_top_level_mentions_get_distinct_scopes() { + let ch = Uuid::new_v4(); + let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event()); + let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event()); + assert_ne!( + a, b, + "two independent top-level mentions must not share a session" + ); + } + + #[test] + fn mixed_case_root_spellings_share_one_thread_scope() { + // The relay decodes event ids to bytes, so `AB…` and `ab…` name the + // same thread. Equivalent-case root tags must resolve to the SAME + // `SessionScope::Thread` key, or thread state would split in two. + let ch = Uuid::new_v4(); + let root_lower = "a1b2c3d4e5f6".repeat(4) + &"0".repeat(16); // 64 hex + assert_eq!(root_lower.len(), 64); + let root_upper = root_lower.to_ascii_uppercase(); + + let mk = |root: &str| { + event_with_tags(vec![ + vec!["e".into(), root.to_string(), String::new(), "root".into()], + vec!["e".into(), "f".repeat(64), String::new(), "reply".into()], + ]) + }; + let lower = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_lower)); + let upper = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_upper)); + assert_eq!( + lower, upper, + "case-equivalent root spellings must share one thread scope" + ); + // And the stored key is normalized to lowercase. + assert_eq!(upper.root_event_id(), Some(root_lower.as_str())); + } + + #[test] + fn malformed_thread_tag_falls_back_to_top_level() { + let ch = Uuid::new_v4(); + // A non-64-hex marker id is ignored by the shared NIP-10 resolver, so + // the event is treated as top-level (rooted at its own id). + let ev = event_with_tags(vec![vec![ + "e".into(), + "not-a-valid-hex-id".into(), + String::new(), + "reply".into(), + ]]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn accessors_and_labels() { + let ch = Uuid::new_v4(); + let conv = SessionScope::Conversation { channel_id: ch }; + assert_eq!(conv.channel_id(), ch); + assert_eq!(conv.root_event_id(), None); + assert!(!conv.is_thread()); + assert_eq!(conv.telemetry_label(), "conversation"); + + let root = "abcdef0123456789".repeat(4); // 64 hex chars + let thread = SessionScope::Thread { + channel_id: ch, + root_event_id: root.clone(), + }; + assert_eq!(thread.channel_id(), ch); + assert_eq!(thread.root_event_id(), Some(root.as_str())); + assert!(thread.is_thread()); + assert_eq!(thread.telemetry_label(), "thread:abcdef01"); + } + + #[test] + fn scope_is_hashable_and_usable_as_map_key() { + use std::collections::HashMap; + let ch = Uuid::new_v4(); + let mut map: HashMap = HashMap::new(); + let s1 = SessionScope::Thread { + channel_id: ch, + root_event_id: "a".repeat(64), + }; + let s2 = SessionScope::Conversation { channel_id: ch }; + *map.entry(s1.clone()).or_insert(0) += 1; + *map.entry(s1.clone()).or_insert(0) += 1; + *map.entry(s2).or_insert(0) += 1; + assert_eq!(map.get(&s1), Some(&2)); + assert_eq!(map.len(), 2); + } +} diff --git a/crates/buzz-acp/src/session_model_channel.md b/crates/buzz-acp/src/session_model_channel.md new file mode 100644 index 00000000000..58f652aa3c2 --- /dev/null +++ b/crates/buzz-acp/src/session_model_channel.md @@ -0,0 +1,5 @@ +## 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. Threads within a channel share that channel's session. DMs stay one conversation. Sessions share your core memory, your workspace on disk, relay access, and channel authorization. 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. diff --git a/crates/buzz-acp/src/session_model_thread.md b/crates/buzz-acp/src/session_model_thread.md new file mode 100644 index 00000000000..5665520b8d9 --- /dev/null +++ b/crates/buzz-acp/src/session_model_thread.md @@ -0,0 +1,5 @@ +## Session Model + +You are one session of your agent identity — not the only copy. In channels, each thread gets its own independent conversation context, including a new thread rooted at a top-level mention. DMs stay one conversation, not separate sessions per thread. Multiple sessions of the same agent may be active in different channels or different threads in the same channel at the same time. Sessions share your core memory, your workspace on disk, relay access, and channel authorization. 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 or a sibling channel thread, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this session, 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. diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea46..70b5a8dcb28 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -71,10 +71,11 @@ pub(crate) enum AcpAvailabilityStatus { } use crate::{ - author_allowed, config::Config, event_mentions_agent, filter, - relay::{HarnessRelay, RelayEventPublisher}, + inbound_author_gate::AuthorizedListenerEvent, + relay::{self, HarnessRelay, RelayEventPublisher}, + InboundAuthorGate, OwnerCache, }; // ── Payload ─────────────────────────────────────────────────────────────────── @@ -342,6 +343,10 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::info!("setup-mode: connected and subscribed to membership notifications"); + let rest_client = relay.rest_client(); + let mut author_gate_ctx = + crate::InboundAuthorGate::connect(&rest_client, &pubkey_hex, "setup startup").await; + // Resolve owner for author-gate (same priority as normal mode). let startup_owner = crate::resolve_agent_owner(&config); let owner_cache = crate::OwnerCache::new(startup_owner); @@ -381,7 +386,6 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> } let publisher = relay.event_publisher(); - let rest_client = relay.rest_client(); let channel_info = crate::pool::ChannelInfoResolver::new(channel_info_map, rest_client.clone()); @@ -428,80 +432,115 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let author_hex = buzz_event.event.pubkey.to_hex(); - let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; - let allowed = author_allowed( + let Some(authorized_event) = authorize_setup_listener_event( + &mut author_gate_ctx, + buzz_event, &config.respond_to, &config.respond_to_allowlist, - &author_hex, - is_dm, &owner_cache, + &channel_info, &rest_client, ) - .await; + .await + else { + continue; + }; - // Apply channel/kind filter rules. - let filter_matched = filter::match_event( - &buzz_event.event, - buzz_event.channel_id, + if !nudge_authorized_event( + authorized_event, &rules, &pubkey_hex, - ) - .await - .is_some(); - - // Pure gate: author gate verdict + event-id dedup. - if !should_nudge_for_event( - buzz_event.event.id, - allowed, - filter_matched, &mut nudged_event_ids, - ) { - continue; - } - - // Build and publish the setup nudge. - if let Err(e) = publish_setup_nudge( &publisher, &config.keys, - buzz_event.channel_id, - &buzz_event.event, &payload, ) .await { - tracing::warn!("setup-mode: failed to publish nudge: {e}"); - } else { - tracing::info!( - channel_id = %buzz_event.channel_id, - event_id = %buzz_event.event.id, - "setup-mode: nudge published" - ); + continue; } } Ok(()) } -/// Outcome of the pure per-event gate checks in setup mode. +async fn nudge_authorized_event( + authorized_event: AuthorizedListenerEvent, + rules: &[filter::SubscriptionRule], + pubkey_hex: &str, + nudged_event_ids: &mut HashSet, + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + payload: &SetupPayload, +) -> bool { + let (buzz_event, effective_author) = authorized_event.into_parts(); + + // Apply channel/kind filter rules. + let filter_matched = + filter::match_event(&buzz_event.event, buzz_event.channel_id, rules, pubkey_hex) + .await + .is_some(); + + if !should_nudge_for_event(buzz_event.event.id, filter_matched, nudged_event_ids) { + return false; + } + + // Build and publish the setup nudge. + if let Err(e) = publish_setup_nudge( + publisher, + keys, + buzz_event.channel_id, + &buzz_event.event, + &effective_author, + payload, + ) + .await + { + tracing::warn!("setup-mode: failed to publish nudge: {e}"); + } else { + tracing::info!( + channel_id = %buzz_event.channel_id, + event_id = %buzz_event.event.id, + "setup-mode: nudge published" + ); + } + true +} + +pub(super) async fn authorize_setup_listener_event( + author_gate: &mut InboundAuthorGate, + buzz_event: relay::BuzzEvent, + respond_to: &crate::config::RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &crate::pool::ChannelInfoResolver, + rest_client: &relay::RestClient, +) -> Option { + author_gate + .authorize_listener_event( + buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await +} + +/// Outcome of the synchronous per-event setup checks. /// -/// Callers compute the async gates (`author_allowed`, `filter::match_event`) -/// up-front, then pass the boolean results here. This helper handles -/// everything that is synchronous and stateful: the author gate verdict -/// and event-id dedup. +/// This helper owns only filter matching and event-id deduplication; the +/// production path can call it only through `nudge_authorized_event`, whose +/// input is the gate's private authorized capability. /// /// Returns `true` when the event should produce a nudge. #[must_use] pub(crate) fn should_nudge_for_event( event_id: EventId, - author_allowed: bool, filter_matched: bool, nudged_event_ids: &mut HashSet, ) -> bool { - if !author_allowed { - tracing::debug!("setup-mode: event filtered by author gate"); - return false; - } if !filter_matched { return false; } @@ -591,12 +630,13 @@ async fn handle_setup_membership( /// Build and publish a setup nudge reply to the triggering event. /// /// Threading: flat reply to the thread root if one exists; otherwise reply -/// to the triggering event itself. P-tags the asker. +/// to the triggering event itself. P-tags the verified effective asker. async fn publish_setup_nudge( publisher: &RelayEventPublisher, keys: &nostr::Keys, channel_id: Uuid, triggering_event: &nostr::Event, + recipient_hex: &str, payload: &SetupPayload, ) -> Result<()> { use buzz_sdk::ThreadRef; @@ -621,15 +661,15 @@ async fn publish_setup_nudge( }; let body = payload.nudge_body(); - let author_hex = triggering_event.pubkey.to_hex(); let event_builder = buzz_sdk::build_message( channel_id, &body, thread_ref.as_ref(), - &[&author_hex], // p-tag the asker + &[recipient_hex], // p-tag the verified effective asker false, &[], + &[], ) .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; @@ -699,6 +739,89 @@ mod tests { )); } + #[tokio::test] + async fn authorized_workflow_nudge_mentions_effective_owner_not_relay_signer() { + let agent_keys = nostr::Keys::generate(); + let relay_keys = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let channel_id = Uuid::new_v4(); + let event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: crate::author_gate_tests::relay_signed_workflow_dispatch( + &relay_keys, + &workflow_owner, + &agent, + ), + }; + let relay_hex = relay_keys.public_key().to_hex(); + let (rest_client, server) = + crate::author_gate_tests::nip11_server(serde_json::json!({ "self": relay_hex })).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "setup nudge test").await; + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + let channel_info = crate::pool::ChannelInfoResolver::new( + std::collections::HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let authorized = authorize_setup_listener_event( + &mut gate, + event, + &crate::config::RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await + .expect("workflow owner should pass the setup author gate"); + let rules = vec![filter::SubscriptionRule { + name: "workflow".into(), + channels: filter::ChannelScope::All("all".into()), + ..Default::default() + }]; + let (publisher, mut published) = RelayEventPublisher::test_pair(); + let payload = SetupPayload { + agent_name: "Fizz".into(), + agent_pubkey: agent.clone(), + requirements: vec![], + }; + + assert!( + nudge_authorized_event( + authorized, + &rules, + &agent, + &mut HashSet::new(), + &publisher, + &agent_keys, + &payload, + ) + .await + ); + let nudge = published.recv().await.expect("setup nudge published"); + let recipients: Vec<&str> = nudge + .tags + .iter() + .filter_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("p")) + .then(|| values.get(1).map(String::as_str)) + .flatten() + }) + .collect(); + assert!(recipients.contains(&workflow_owner.as_str())); + assert!(!recipients.contains(&relay_hex.as_str())); + server.abort(); + } + #[test] fn nudge_body_names_all_requirements() { let payload = SetupPayload { @@ -988,32 +1111,25 @@ mod tests { // ── should_nudge_for_event gate tests ───────────────────────────────────── // - // These tests exercise the loop-wiring for the two safety-critical guards: - // (a) non-allowlisted author → no nudge, (b) same event-id → exactly one - // nudge. They use the extracted `should_nudge_for_event` helper, which is - // the exact code the live loop calls. + // These tests exercise the loop-adjacent synchronous guards after an event + // has passed the structurally mandatory author capability: (a) unmatched + // filter → no nudge, (b) same event-id → exactly one nudge. fn fake_event_id(byte: u8) -> EventId { EventId::from_byte_array([byte; 32]) } #[test] - fn test_non_allowlisted_author_returns_no_nudge() { - // author_allowed = false → should return false regardless of other args. + fn test_unmatched_filter_returns_no_nudge() { let mut dedup: HashSet = HashSet::new(); let event_id = fake_event_id(0xAA); - let result = should_nudge_for_event( - event_id, false, // author NOT allowed - true, // filter matched — would otherwise nudge - &mut dedup, - ); + let result = should_nudge_for_event(event_id, false, &mut dedup); - assert!(!result, "non-allowlisted author must not produce a nudge"); - // Dedup set must remain empty — no phantom insertion for blocked author. + assert!(!result, "unmatched event must not produce a nudge"); assert!( dedup.is_empty(), - "dedup set must not record event for blocked author" + "dedup set must not record an unmatched event" ); } @@ -1024,19 +1140,11 @@ mod tests { let mut dedup: HashSet = HashSet::new(); let event_id = fake_event_id(0xBB); - let first = should_nudge_for_event( - event_id, true, // allowed - true, // matched - &mut dedup, - ); + let first = should_nudge_for_event(event_id, true, &mut dedup); assert!(first, "first occurrence must be accepted"); // Simulate reconnect replay: same event arrives again. - let second = should_nudge_for_event( - event_id, true, // allowed - true, // matched - &mut dedup, - ); + let second = should_nudge_for_event(event_id, true, &mut dedup); assert!( !second, "replay of the same event-id must be rejected (dedup)" diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 42a7de84f7c..19a3b1d9d48 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -433,10 +433,13 @@ async fn connect_member_services() -> Result<(Db, Arc, Keys)> { async fn connect_db() -> Result { let db_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); - let db = Db::new(&DbConfig { - database_url: db_url, - ..DbConfig::default() - }) + let db = Db::new( + &DbConfig { + database_url: db_url, + ..DbConfig::default() + } + .with_session_timeouts_from_env(), + ) .await?; Ok(db) } diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index fabf75754e1..b60644bb7b6 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -24,6 +24,24 @@ path = "src/main.rs" name = "fake-mcp" path = "tests/bin/fake_mcp.rs" +# Test-only lock holder: a real second process that takes the coordinator's +# cross-process advisory lock, so the auth tests can prove genuine +# inter-process single-flight and crash-release rather than same-process +# handles. Tiny; only used by the databricks auth integration tests. +[[bin]] +name = "lock-holder" +path = "tests/bin/lock_holder.rs" + +# Test-only auth worker: a real second process that runs the PUBLIC auth +# coordinator API (`acquire_with_intent`) with a scripted browser opener and a +# shared temp cache, so the auth tests can prove the cross-process single-flight +# contract end-to-end — durable cooldown sharing and one-grant/one-cache races +# across a genuine process boundary, not two in-process handles. Only used by +# the databricks auth integration tests. +[[bin]] +name = "auth-worker" +path = "tests/bin/auth_worker.rs" + [dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] } serde = { workspace = true } @@ -45,6 +63,11 @@ url = { workspace = true } urlencoding = "2" webbrowser = "1" dirs = "6" +# Cross-process advisory file lock (flock on Unix, LockFileEx on Windows) for +# the auth coordinator's single-flight. Kept off std's `File::try_lock` so the +# crate stays buildable on the repo's declared 1.88 MSRV (those std APIs are +# 1.89+). +fs2 = "0.4" [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "process"] } diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 56e62cf9e79..f2d68d4d8cd 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -159,7 +159,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | | `BUZZ_AGENT_MAX_HANDOFFS` | `10` | Max context handoffs per session before falling back to truncation. | | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). | -| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds | +| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `1260` | Per-tool call timeout in seconds | | `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) | | `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. | | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | @@ -326,7 +326,7 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP | Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` | | Loop rounds | 0 (unlimited) | `BUZZ_AGENT_MAX_ROUNDS` | | LLM read inactivity timeout | 240 s | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | -| Tool call timeout | 660 s | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | +| Tool call timeout | 1260 s | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | ## What This Is NOT diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index a78a499bdd1..0ae34318c27 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -15,19 +15,21 @@ //! captures the redirect, and exchanges the code for a token. Subsequent //! calls hit the cache and silently refresh when expired. +use std::collections::HashMap; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use base64::Engine; +use fs2::FileExt; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::Digest; -use tokio::sync::Mutex; +use tokio::sync::{watch, Mutex}; use crate::types::AgentError; @@ -39,6 +41,219 @@ const TOKEN_REFRESH_LEEWAY: Duration = Duration::from_secs(60); /// We match: any longer and the user has gone to lunch. const BROWSER_AUTH_TIMEOUT: Duration = Duration::from_secs(60); +/// Per-request network timeout for every OAuth HTTP call (discovery, refresh +/// grant, code exchange). Without this, a hung provider connection would stall +/// the caller — and, worse, stall every same-key caller waiting on the +/// cross-process lock this holder owns. +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Longest an in-flight auth attempt can legitimately run: cold discovery +/// (`30s`) + browser wait (`60s`) + code exchange (`30s`), plus a failed +/// refresh (`30s`) ahead of the browser. Rounded to `150s`. A waiter derives +/// its lock-wait bound from this so it never times out ahead of a healthy +/// holder. +const AUTH_ATTEMPT_DEADLINE: Duration = Duration::from_secs(150); + +/// How long a same-key caller waits to acquire the cross-process lock before +/// giving up with [`AuthError::LockTimeout`]. Deliberately longer than +/// [`AUTH_ATTEMPT_DEADLINE`] so a waiter outlasts any legitimate holder rather +/// than timing out mid-flow. +const LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(165); + +/// Poll interval for deadline-aware lock acquisition. `try_lock` is +/// non-blocking, so we sleep between attempts rather than blocking a worker. +const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// How long a failed interactive (browser) attempt suppresses automatic +/// re-launch for the same key. Long enough that a spurned dropdown does not +/// re-pop a browser on the next debounced refresh, short enough that a user +/// who fixes the problem is not locked out. +const COOLDOWN_DURATION: Duration = Duration::from_secs(300); + +/// Why an auth acquisition wants a token, which decides whether it may open a +/// browser and whether it honors a cooldown. +/// +/// - [`Auto`](Self::Auto): passive Desktop discovery (create/edit/defaults/ +/// onboarding). May open a browser, but honors an unexpired cooldown and +/// returns its recorded outcome instead of re-launching. +/// - [`UserInitiated`](Self::UserInitiated): an explicit human action — the +/// saved-agent model picker or `buzz-agent auth databricks`. May open a +/// browser and *bypasses* the cooldown (the user asked for it now). +/// - [`Headless`](Self::Headless): managed-runtime inference and provider +/// preflight. Never opens a browser; may consume another attempt's cached +/// success but never becomes the initiator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AuthIntent { + Auto, + UserInitiated, + Headless, +} + +impl AuthIntent { + /// `true` for the intents permitted to open a browser. + fn may_open_browser(self) -> bool { + matches!(self, Self::Auto | Self::UserInitiated) + } + + /// `true` for the one intent that honors a recorded cooldown on read. + fn honors_cooldown(self) -> bool { + matches!(self, Self::Auto) + } + + /// Stable discriminant for the cross-process attempt sidecar. A queued + /// caller adopts a completed attempt's failure only when the recorded + /// intent matches its own — the durable mirror of the in-process + /// [`INFLIGHT`] registry's `(path, intent)` keying, so a `UserInitiated` + /// caller never inherits an `Auto` attempt's suppressed result across + /// processes any more than it does within one. + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::UserInitiated => "user_initiated", + Self::Headless => "headless", + } + } +} + +/// Typed result of an auth acquisition. `Ok` carries the bearer; the error +/// arm classifies *why* no token was produced so callers (and, in Phase 2, the +/// Tauri boundary) can branch on a stable code instead of matching display +/// text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthError { + /// No cached token, no refresh grant, and the caller may not open a + /// browser (`Headless`). + NoCredential, + /// The user (or provider) rejected the browser authorization. + Denied, + /// The browser flow was not completed within [`BROWSER_AUTH_TIMEOUT`]. + TimedOut, + /// Every browser-launch strategy failed, so the flow never started. + BrowserOpenFailed, + /// An OAuth network call (discovery/refresh/exchange) could not reach the + /// provider or timed out. + NetworkUnavailable, + /// A refresh-token grant was rejected (dead/rotated refresh token) and the + /// caller may not fall back to a browser. + RefreshRejected, + /// The authorization-code exchange itself was rejected by the token + /// endpoint (distinct from a refresh rejection). + ExchangeFailed, + /// Could not acquire the cross-process auth lock within + /// [`LOCK_WAIT_TIMEOUT`]. + LockTimeout, +} + +impl AuthError { + /// Stable machine-readable code. Phase 2 serializes this across the Tauri + /// boundary (the `project_git_merge_error` `{code, message}` precedent) so + /// the Desktop formatter switches on the code, never on display text. + pub fn code(&self) -> &'static str { + match self { + Self::NoCredential => "no_credential", + Self::Denied => "denied", + Self::TimedOut => "timed_out", + Self::BrowserOpenFailed => "browser_open_failed", + Self::NetworkUnavailable => "network_unavailable", + Self::RefreshRejected => "refresh_rejected", + Self::ExchangeFailed => "exchange_failed", + Self::LockTimeout => "lock_timeout", + } + } + + /// `true` for the browser-attempt outcomes worth recording in the cooldown + /// sidecar — the failures that would otherwise re-pop a browser on the + /// next automatic attempt. Non-browser failures (no credential, refresh + /// rejection, lock timeout, network) are not recorded. + fn is_cooldown_worthy(&self) -> bool { + matches!( + self, + Self::Denied | Self::TimedOut | Self::BrowserOpenFailed | Self::ExchangeFailed + ) + } + + /// Reconstruct a recorded outcome from its [`code`](Self::code). The + /// cooldown-worthy variants always round-trip; `RefreshRejected` and + /// `NoCredential` are also reconstructed for the cross-process attempt + /// adoption path. Any other code (a forward-compat sidecar written by a + /// newer buzz-agent) yields `None`, treated as "no active record" rather + /// than a hard failure. + fn from_code(code: &str) -> Option { + match code { + "denied" => Some(Self::Denied), + "timed_out" => Some(Self::TimedOut), + "browser_open_failed" => Some(Self::BrowserOpenFailed), + "exchange_failed" => Some(Self::ExchangeFailed), + "refresh_rejected" => Some(Self::RefreshRejected), + "no_credential" => Some(Self::NoCredential), + _ => None, + } + } + + fn message(&self) -> String { + match self { + Self::NoCredential => { + "no cached Databricks token; run `buzz-agent auth databricks` first".into() + } + Self::Denied => "Databricks authorization was denied".into(), + Self::TimedOut => "Databricks browser authorization timed out".into(), + Self::BrowserOpenFailed => "could not open a browser for Databricks sign-in".into(), + Self::NetworkUnavailable => "could not reach Databricks to authenticate".into(), + Self::RefreshRejected => "Databricks rejected the refresh token; sign in again".into(), + Self::ExchangeFailed => "Databricks rejected the authorization code".into(), + Self::LockTimeout => "timed out waiting for a concurrent Databricks sign-in".into(), + } + } +} + +impl From for AgentError { + /// Map a typed auth failure onto the crate error the [`TokenSource`] trait + /// returns. Auth-decision failures become [`AgentError::LlmAuth`] so the + /// caller's retry loop stops instead of hammering a rejected credential; + /// purely infrastructural failures (network, lock contention) become + /// [`AgentError::Llm`], matching the pre-coordinator classification of a + /// discovery/network error. + fn from(e: AuthError) -> Self { + match e { + AuthError::NetworkUnavailable | AuthError::LockTimeout => AgentError::Llm(e.message()), + AuthError::NoCredential + | AuthError::Denied + | AuthError::TimedOut + | AuthError::BrowserOpenFailed + | AuthError::RefreshRejected + | AuthError::ExchangeFailed => AgentError::LlmAuth(e.message()), + } + } +} + +/// Opens a URL for the interactive browser step. Injected so the PKCE +/// continuation (callback listener, verifier, timeout) stays alive across the +/// launch: the coordinator calls this *while* the localhost listener is +/// bound, so a launch failure never leaves a returned URL pointing at a torn +/// down listener. Desktop (Phase 2) supplies the Tauri opener; the CLI uses +/// [`DefaultBrowserOpener`], which prints the URL and opens the system +/// browser. +pub trait BrowserOpener: Send + Sync { + /// Attempt to present `url` to the user. Returning `Err` means every + /// launch strategy for this opener failed; the coordinator then reports + /// [`AuthError::BrowserOpenFailed`] without waiting on a listener nobody + /// will reach. + fn open(&self, url: &str) -> Result<(), String>; +} + +/// Default opener: print the URL (so a user on a headless box can copy it) +/// and open the system browser. Printing is itself a launch strategy, so this +/// never reports failure — the URL is always visible to the waiting user. +pub struct DefaultBrowserOpener; + +impl BrowserOpener for DefaultBrowserOpener { + fn open(&self, url: &str) -> Result<(), String> { + eprintln!("Opening browser for authentication. If it doesn't open, visit:\n {url}"); + let _ = webbrowser::open(url); + Ok(()) + } +} + /// Asynchronous source of a bearer token. The [`Llm`] calls this per /// request, so impls are expected to be cheap on the cache-hit path. #[async_trait] @@ -93,8 +308,9 @@ impl TokenSource for StaticTokenSource { /// /// The `discovery_url` must return a JSON document with at least /// `authorization_endpoint` and `token_endpoint` (RFC 8414). The -/// `cache_namespace` is the directory under `~/.config/buzz-agent/oauth/` -/// the token JSON lives in — separates providers' caches cleanly. +/// `cache_namespace` is the directory under the platform config directory's +/// `buzz-agent/oauth/` root where the token JSON lives — separates providers' +/// caches cleanly. #[derive(Debug, Clone)] pub struct PkceOAuthConfig { pub discovery_url: String, @@ -102,7 +318,7 @@ pub struct PkceOAuthConfig { pub scopes: Vec, pub cache_namespace: String, /// When `Some`, the engine writes tokens here instead of - /// `~/.config/buzz-agent/oauth//`. Production code + /// `/buzz-agent/oauth//`. Production code /// leaves this `None`. Integration tests use it to avoid stomping on /// a shared `$HOME` when running in parallel. pub cache_dir_override: Option, @@ -123,6 +339,25 @@ struct OidcEndpoints { token_endpoint: String, } +/// Typed result of a refresh-token grant, so the coordinator can separate an +/// actual credential rejection from a transient fault. +/// +/// - [`Refreshed`](Self::Refreshed): a fresh token — success. +/// - [`Rejected`](Self::Rejected): the token endpoint returned an +/// `invalid_grant` error (dead/rotated refresh token). This is the only +/// outcome that becomes [`AuthError::RefreshRejected`] for `Headless` or +/// drives a browser fallback for interactive intents. +/// - [`Network`](Self::Network): transport error, timeout, 5xx, any 4xx that +/// is not `invalid_grant` (e.g. `invalid_request`, `invalid_client`, 429), +/// an unparseable error body, or an undecodable/malformed success body — +/// infrastructural or misconfiguration, never a credential decision, so it +/// surfaces as [`AuthError::NetworkUnavailable`] and never pops a browser. +enum RefreshOutcome { + Refreshed(CachedToken), + Rejected, + Network, +} + /// PKCE OAuth token source with on-disk refresh cache. /// /// First call: @@ -136,27 +371,93 @@ pub struct PkceOAuthTokenSource { cfg: PkceOAuthConfig, http: Client, cache_path: PathBuf, - /// Single-flight guard: only one refresh/browser flow at a time, even - /// if many tool calls land concurrently. + /// Injected browser launcher, called inside [`browser_pkce_flow`] while the + /// localhost listener is live. Production uses [`DefaultBrowserOpener`]; + /// Phase 2 supplies the Tauri opener. + opener: Arc, + /// In-memory single-flight *and* fast-path cache. The cross-process file + /// lock serializes slow-path work; this cell keeps the fast path off disk + /// during a turn and off the lock entirely. state: Mutex>, } impl PkceOAuthTokenSource { + /// Construct with the default browser opener (prints the URL and opens the + /// system browser). This is the signature every production call site uses. pub fn new(cfg: PkceOAuthConfig) -> Result, AgentError> { + Self::new_with(cfg, Arc::new(DefaultBrowserOpener)) + } + + /// Construct with an injected [`BrowserOpener`]. Tests substitute a + /// recording/failing opener to exercise the browser branch without a real + /// window; Phase 2 Desktop injects the Tauri opener. + pub fn new_with( + cfg: PkceOAuthConfig, + opener: Arc, + ) -> Result, AgentError> { + Self::new_with_http_timeout(cfg, opener, HTTP_REQUEST_TIMEOUT) + } + + /// Construct with an injected opener *and* an explicit per-request HTTP + /// timeout. Only the refresh-timeout integration test passes the timeout + /// argument: it drives a hung token endpoint against a short bound so the + /// per-request timeout classification (`NetworkUnavailable`, never + /// `RefreshRejected`) is exercised in real time. A paused-clock test can't + /// do this — tokio auto-advances into the timer while the real loopback + /// discovery call is still in flight, tripping the timeout on the wrong + /// request. Every production and other-test path goes through + /// [`new`](Self::new) or [`new_with`](Self::new_with) at the default + /// [`HTTP_REQUEST_TIMEOUT`]. + pub fn new_with_http_timeout( + cfg: PkceOAuthConfig, + opener: Arc, + http_timeout: Duration, + ) -> Result, AgentError> { let cache_path = cache_path_for(&cfg)?; if let Some(parent) = cache_path.parent() { fs::create_dir_all(parent) .map_err(|e| AgentError::Llm(format!("oauth cache dir {parent:?}: {e}")))?; } + // Every OAuth HTTP call inherits this timeout so a hung provider can + // never stall the caller — nor the same-key callers waiting on the + // cross-process lock this holder owns. Construction is fallible, so a + // build failure propagates rather than silently falling back to an + // untimed client — an untimed client would restore exactly the + // unbounded-HTTP-under-lock failure the timeout exists to prevent. + let http = Client::builder() + .timeout(http_timeout) + .build() + .map_err(|e| AgentError::Llm(format!("oauth http client: {e}")))?; let initial = read_cache(&cache_path); Ok(Arc::new(Self { cfg, - http: Client::new(), + http, cache_path, + opener, state: Mutex::new(initial), })) } + /// Path of the cross-process advisory lock file guarding slow-path auth + /// for this cache key. Co-located with the cache so it shares the + /// per-key directory and `$HOME` override. + fn lock_path(&self) -> PathBuf { + append_ext(&self.cache_path, "lock") + } + + /// Path of the cooldown sidecar recording the last browser-attempt + /// failure for this cache key. + fn cooldown_path(&self) -> PathBuf { + append_ext(&self.cache_path, "cooldown") + } + + /// Path of the attempt sidecar recording the generation and outcome of the + /// last completed slow-path acquisition for this cache key. Drives the + /// cross-process single-flight of *failures* (see [`AttemptRecord`]). + fn attempt_path(&self) -> PathBuf { + append_ext(&self.cache_path, "attempt") + } + /// Discover authorization + token endpoints from the well-known URL. async fn endpoints(&self) -> Result { let v: Value = self @@ -193,236 +494,848 @@ impl PkceOAuthTokenSource { /// The cache holds both the access and refresh tokens, so the on-disk /// file is written owner-only (`0o600` on Unix) via an atomic /// inode-swapping rename — see [`write_private_cache`]. + /// + /// On non-Unix platforms the token is stored in-memory only: the + /// `write_private_cache` path creates files with default ACLs, which do + /// not enforce owner-only access. Disk persistence is intentionally + /// disabled until a Windows-specific owner-only DACL is implemented (see + /// the `create_private_temp_file` non-Unix branch). The cost is that each + /// process performs its own acquisition on non-Unix — cross-process + /// *success* handoff requires the shared on-disk cache, so processes + /// serialize through the lock but the loser repeats the flow rather than + /// reading the winner's token. Cross-process *failure* adoption still works + /// because it uses the attempt sidecar (no token bytes). Correct and + /// safe until owner-only DACL persistence exists. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { - let body = serde_json::to_vec_pretty(&token) - .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - write_private_cache(&self.cache_path, &body).map_err(|e| { - AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) - })?; + self.persist(&token)?; *state = Some(token); Ok(()) } + /// Write `token` to the on-disk cache. Split out of [`save`](Self::save) so + /// the 401 neutralization path can rewrite the disk layer without clobbering + /// a distinct in-memory entry. No-op on non-Unix (see [`save`](Self::save)). + fn persist(&self, token: &CachedToken) -> Result<(), AgentError> { + #[cfg(unix)] + { + let body = serde_json::to_vec_pretty(token) + .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; + } + #[cfg(not(unix))] + { + // Disk persistence disabled on non-Unix: owner-only file + // permissions require a DACL that is not yet implemented. + let _ = token; + } + Ok(()) + } + + /// Neutralize the matching rejected credential in B's own in-memory `state` + /// only — no disk I/O. The joiner matching-failure path calls this rather + /// than `expire_rejected`: the leader already ran the durable disk + /// invalidation under the cross-process file lock, and re-running disk + /// mutations from the lockless joiner can race with a concurrent process C + /// that persisted a valid replacement under the same lock (C's rename can + /// be overwritten by B's unfenced rename). + /// + /// Contract: only the access-token identity is checked — the refresh token + /// is left intact so callers reaching the recovery disk-read path below can + /// still attempt a fresh token exchange with the un-revoked refresh secret. + /// + /// Limitation: the joiner's match arm triggers on a same-digest leader + /// error regardless of error code (see `acquire`'s `Err` match arm). A + /// pre-lock failure (e.g. `LockTimeout`) with a matching rejected digest + /// therefore also reaches this helper, even though the leader never + /// durably invalidated the disk copy. In that case B's in-memory entry is + /// neutralized and B returns the shared error; the disk copy survives + /// intact. A subsequent plain `bearer()` (`rejected = None`) can re-read + /// the disk entry. This is a known bounded limitation: in-memory + /// neutralization is applied without a guarantee that the durable copy is + /// also gone. + fn expire_rejected_memory(&self, state: &mut Option, rejected: Option<&str>) { + let Some(rej) = rejected else { return }; + if let Some(tok) = state.as_mut() { + if tok.access_token == rej { + tok.expires_at = Some(0); + } + } + } + + /// Neutralize a cached token the caller just reported 401-rejected. + /// + /// A 401 means the cached access token is dead even though its local expiry + /// clock still looks fresh. [`cached_hit`](Self::cached_hit) and + /// [`usable_from_disk`](Self::usable_from_disk) already exclude it for a + /// caller carrying `rejected`, but a *later* plain `bearer()` + /// (`rejected = None`) trusts the clock and would serve it, and a freshly + /// constructed source would restore it from disk. Force it expired in both + /// layers so [`is_expired`] excludes it for every future caller and every + /// fresh process, while the refresh token — which was *not* rejected and + /// drives this very recovery — stays intact. Each layer is neutralized only + /// when its access token byte-equals `rejected`, so a sibling's + /// concurrently-written distinct replacement is preserved. + /// + /// Disk neutralization is a bounded three-stage process: on atomic-rewrite + /// failure (e.g. non-writable parent directory), the implementation falls + /// back to an in-place truncating overwrite of the existing file (no + /// parent-dir perms required), and finally to `remove_file`. If all three + /// fail the file survives; `cached_hit`'s `rejected`-aware filter protects + /// this caller's path, but a later plain `bearer()` could re-read the + /// unexpired file. That residual corner is outside the normal threat model + /// (owner actively hardening their own cache file to 0400 against their own + /// process). + fn expire_rejected(&self, state: &mut Option, rejected: Option<&str>) { + let Some(rej) = rejected else { return }; + // Neutralize the in-memory entry: force-expire so `is_expired` excludes + // it for every subsequent in-process caller, while the refresh token + // (which was not rejected) stays intact for the recovery below. + if let Some(tok) = state.as_mut() { + if tok.access_token == rej { + tok.expires_at = Some(0); + } + } + // Neutralize the on-disk copy. Prefer atomic rewrite via `persist()` + // (temp-file + rename, owner-only permissions). If the atomic rewrite + // fails (e.g. the parent directory denies temp-file creation), fall back + // to in-place truncating overwrite: `OpenOptions::write().truncate(true)` + // on the existing file does not require parent-directory write permission, + // only that the file itself is owner-writable (0600, which our cache files + // always are). As a last resort, attempt `remove_file`. The two-stage + // fallback covers the proven hostile case: a 0600 token file under a + // 0500 parent — the atomic path cannot create the temp file (EACCES), but + // the in-place write succeeds because the file's own mode permits it. + // Residual out of threat model: if the owner explicitly chmodded their own + // cache file to 0400 before this runs, the in-place write also fails and + // we fall through to `remove_file`; if that too fails, the file survives + // with `expires_at = 0` still NOT written — `cached_hit`'s + // `rejected`-aware filter still protects the calling 401-recovery path, + // but a later plain `bearer()` could re-adopt the file. That corner is + // not in the normal threat model (a user actively hardening their own + // cache file against their own process). + if let Some(mut disk) = read_cache(&self.cache_path) { + if disk.access_token == rej { + disk.expires_at = Some(0); + if self.persist(&disk).is_err() { + // Atomic rewrite failed. Try in-place truncating overwrite — + // does not need parent-dir write permission, only the file's + // own mode. + let inplace_ok = serde_json::to_vec_pretty(&disk).ok().is_some_and(|body| { + use std::io::Write as _; + fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&self.cache_path) + .and_then(|mut f| f.write_all(&body)) + .is_ok() + }); + if !inplace_ok { + let _ = fs::remove_file(&self.cache_path); + } + } + } + } + } + /// Exchange a refresh token for a fresh access token. - async fn refresh( - &self, - endpoints: &OidcEndpoints, - refresh_token: &str, - ) -> Result { + /// + /// The outcome is typed so the caller can tell an actual credential + /// rejection apart from a transient fault. Only a token-endpoint rejection + /// of the grant itself (a 4xx `invalid_grant`-class response) is a dead + /// refresh token; a transport failure, timeout, 5xx, or an + /// undecodable/malformed response is infrastructural and must never be + /// mistaken for a credential decision (it would otherwise pop a browser or + /// return `RefreshRejected` when nothing was actually rejected). + async fn refresh(&self, endpoints: &OidcEndpoints, refresh_token: &str) -> RefreshOutcome { let params = [ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", &self.cfg.client_id), ]; - let resp = self + let resp = match self .http .post(&endpoints.token_endpoint) .form(¶ms) .send() .await - .map_err(|e| AgentError::Llm(format!("oauth refresh: {e}")))?; - if !resp.status().is_success() { + { + Ok(resp) => resp, + // Transport error or the per-request timeout elapsed: no verdict + // from the provider, so this is infrastructural, not a rejection. + Err(e) => { + tracing::warn!(error = %e, "oauth refresh transport failure"); + return RefreshOutcome::Network; + } + }; + let status = resp.status(); + if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return Err(AgentError::Llm(format!("oauth refresh failed: {body}"))); + // Per RFC 6749 §5.2 only `error == "invalid_grant"` means the + // refresh token itself is dead (expired/revoked) — the one failure + // a browser sign-in can repair. Every other 4xx (`invalid_request`, + // `invalid_client`, `unsupported_grant_type`, `invalid_scope`, 408, + // 429, …), an unparseable error body, and all 5xx are + // infrastructural or misconfiguration: a browser can't fix them, so + // they stay in the non-credential bucket and surface as + // `NetworkUnavailable` without ever popping a browser. + if status.is_client_error() + && serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_owned)) + .as_deref() + == Some("invalid_grant") + { + tracing::warn!(status = %status, body = %body, "oauth refresh grant rejected"); + return RefreshOutcome::Rejected; + } + tracing::warn!(status = %status, body = %body, "oauth refresh not repairable by browser"); + return RefreshOutcome::Network; + } + let v: Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "oauth refresh response decode failure"); + return RefreshOutcome::Network; + } + }; + match token_from_response(&v, Some(refresh_token)) { + Ok(token) => RefreshOutcome::Refreshed(token), + Err(e) => { + tracing::warn!(error = %e, "oauth refresh response missing access_token"); + RefreshOutcome::Network + } } - let v: Value = resp - .json() - .await - .map_err(|e| AgentError::Llm(format!("oauth refresh json: {e}")))?; - token_from_response(&v, Some(refresh_token)) } - /// Run the full browser-mediated Authorization Code + PKCE flow. - /// Caller must hold a TTY/browser: this opens a window and blocks. + /// Run the full browser-mediated Authorization Code + PKCE flow and cache + /// the result. Routes through the coordinator as a [`UserInitiated`] + /// acquisition: it may open a browser, bypasses (and clears) any cooldown, + /// and single-flights with concurrent callers on the cross-process lock. A + /// still-valid cached token short-circuits to success without re-prompting. + /// + /// This is the no-rejected convenience: it trusts the local expiry clock, + /// so a not-yet-expired cached token is accepted. When the caller already + /// knows the cached bearer was rejected by the server (a 401), it must use + /// [`acquire_with_intent`](Self::acquire_with_intent) with `rejected` set + /// so the stale-but-fresh token can't short-circuit the sign-in. + /// + /// [`UserInitiated`]: AuthIntent::UserInitiated pub async fn interactive_login(&self) -> Result<(), AgentError> { - let endpoints = self.endpoints().await?; - let token = browser_pkce_flow(&self.http, &self.cfg, &endpoints).await?; - let mut state = self.state.lock().await; - self.save(&mut state, token)?; + self.acquire(AuthIntent::UserInitiated, None).await?; Ok(()) } -} -#[async_trait] -impl TokenSource for PkceOAuthTokenSource { - async fn bearer(&self) -> Result { - let mut state = self.state.lock().await; + /// Public entry for passive Desktop discovery and the saved-model picker + /// (Phase 2): acquire a bearer under an explicit [`AuthIntent`], returning + /// the typed [`AuthError`] so the caller can branch on a stable `code` + /// rather than display text. The [`TokenSource`] trait methods wrap this + /// and flatten the error into [`AgentError`]. + /// + /// `rejected` carries the exact access token the provider just 401'd, if + /// any. With `rejected = None` a locally-fresh cached token is a hit (the + /// normal discovery path). With `rejected = Some(t)` the expiry clock is + /// untrustworthy — the rejected token looked fresh — so a cached token + /// equal to `t` is *not* a hit: the acquisition refreshes, and for `Auto` + /// or `UserInitiated` falls through to a browser when the refresh grant is + /// dead. This is what lets the saved-picker recovery path say "this + /// locally-fresh bearer was just rejected — replace it" instead of + /// re-returning the dead token, which `refresh_now`'s hardcoded + /// [`Headless`](AuthIntent::Headless) can never escalate to a browser. + pub async fn acquire_with_intent( + &self, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + self.acquire(intent, rejected).await + } - // 1. In-memory cache hit, still fresh. + /// Return a usable cached bearer, applying the identity rule for a + /// 401-driven acquisition. + /// + /// `rejected = None` (normal): a not-yet-expired cached token is a hit. + /// `rejected = Some(t)`: the expiry clock is untrustworthy — the rejected + /// token looked locally fresh — so a hit requires the cached token to + /// *differ* from `t` (a sibling already replaced it) **and** still be + /// unexpired. Without the expiry check an expired sibling token B could be + /// returned as A's replacement, skipping the refresh the 401 demanded. + /// Checks the in-memory cell first, then re-reads disk (a sibling process + /// may have written a newer token) and adopts it into the cell on a hit. + fn cached_hit( + &self, + state: &mut Option, + rejected: Option<&str>, + ) -> Option { + let usable = + |tok: &CachedToken| !is_expired(tok) && rejected != Some(tok.access_token.as_str()); if let Some(tok) = state.as_ref() { - if !is_expired(tok) { - return Ok(tok.access_token.clone()); + if usable(tok) { + return Some(tok.access_token.clone()); } } - - // 2. Re-read disk — another process may have refreshed already. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + if let Some(disk) = read_cache(&self.cache_path) { + if usable(&disk) { + let bearer = disk.access_token.clone(); + *state = Some(disk); + return Some(bearer); } } + None + } - // 3. Try refresh if we have a refresh token. Discover endpoints once - // here — deliberately hoisted above the refresh-token check so the - // browser flow at step 5 (which also needs them) reuses this call. - let endpoints = self.endpoints().await?; - let refresh = state.as_ref().and_then(|t| t.refresh_token.clone()); - if let Some(rt) = refresh { - match self.refresh(&endpoints, &rt).await { - Ok(fresh) => { - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - return Ok(bearer); - } - Err(e) => { - tracing::warn!(error = %e, "oauth refresh failed; falling back to browser flow"); + /// Lock-free variant of [`cached_hit`]'s disk branch: read the on-disk + /// cache and return its bearer if a sibling wrote a usable replacement for + /// `rejected`. Used by the joiner's shared-failure recheck, where every + /// waiter wakes at once — taking `self.state` (even with `try_lock`) would + /// either drop the replacement for `try_lock` losers or serialize the read + /// behind a new leader holding `state` across its browser flow. The + /// in-memory memo is intentionally not updated; the next real acquisition + /// re-reads and adopts under the lock. + fn usable_from_disk(&self, rejected: Option<&str>) -> Option { + let disk = read_cache(&self.cache_path)?; + (!is_expired(&disk) && rejected != Some(disk.access_token.as_str())) + .then_some(disk.access_token) + } + + /// Discover OIDC endpoints once per flow, memoizing into `slot` so the + /// refresh and browser branches share a single discovery call. A discovery + /// failure (unreachable URL or malformed document) maps to + /// [`AuthError::NetworkUnavailable`] — the infrastructural bucket, so the + /// caller's retry loop treats it as transient rather than as an auth + /// decision. + async fn discover<'a>( + &self, + slot: &'a mut Option, + ) -> Result<&'a OidcEndpoints, AuthError> { + if slot.is_none() { + let eps = self + .endpoints() + .await + .map_err(|_| AuthError::NetworkUnavailable)?; + *slot = Some(eps); + } + Ok(slot.as_ref().expect("endpoints just populated")) + } + + /// The single acquisition entry point behind every [`TokenSource`] method. + /// + /// `intent` decides browser and cooldown policy; `rejected` (`Some` only on + /// a 401-driven refresh) switches cache checks from clock-based to + /// identity-based. The fast path returns a usable cached token without + /// touching the lock or the network. Otherwise the slow path serializes + /// every same-key caller — in this process *and* across processes — on the + /// cross-process advisory lock, so concurrent dialogs coalesce onto one + /// refresh/browser flow instead of racing browsers. + async fn acquire( + &self, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + // Fast path: no lock, no network. `try_lock` rather than `lock().await` + // so a caller arriving while a leader holds `state` across its browser + // flow does not block here — it falls through to the in-process + // registry below and joins the leader instead of waiting out the whole + // flow and then racing in as a second leader. A cache hit is still + // served without the file lock; a miss (or contention) coalesces. + { + if let Ok(mut state) = self.state.try_lock() { + if let Some(hit) = self.cached_hit(&mut state, rejected) { + return Ok(hit); } } + } - // 4. Re-read disk after refresh failure — another process may have won the race. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + // In-process single-flight (see [`INFLIGHT`]). Keyed by (lock path, + // intent): callers with the same intent coalesce, so a caller already + // waiting when the leader's attempt is in flight shares the leader's + // result instead of taking the lock after it and launching a second + // browser. Distinct intents key separately: a `Headless` caller never + // shares a browser-capable slot, and — critically — a `UserInitiated` + // caller never inherits an `Auto` leader's cooldown-suppressed result, + // since the two disagree on cooldown and browser policy. Those cases + // still coordinate through the cross-process file lock. + let key: InflightKey = (self.lock_path(), intent); + let (slot, is_leader) = { + let mut reg = inflight_registry(); + match reg.get(&key) { + Some(existing) => (existing.clone(), false), + None => { + let slot = Arc::new(InflightSlot::new()); + reg.insert(key.clone(), slot.clone()); + (slot, true) + } + } + }; + if !is_leader { + // Pre-existing joiner: observe the leader's outcome, but do not + // adopt a result that violates *this* caller's contract. The slot + // is keyed only by (lock path, intent), so a joiner shares a leader + // that ran with a *different* `rejected` value — and the leader's + // result can be wrong for us in two ways: + // + // * It may publish a token equal to THIS caller's `rejected` + // bytes — e.g. its cache re-read adopted a sibling write we + // just reported 401-rejected. Returning it would retry the + // provider with the exact credentials it refused. We instead + // run our own acquisition: the slot is evicted before publish + // (see [`LeaderGuard::complete`]), so this is a fresh, bounded, + // leader-eligible attempt — not a re-join of the dead + // generation, and not a loop. Its cache re-read excludes our + // `rejected`, and `finish`'s persistence-boundary guard rejects + // any refresh- or browser-issued token equal to our `rejected` + // with a typed error before caching it — so the rerun never + // hands us back our `rejected` on any path. + // + // * It may publish a terminal failure from a *rejection-relative* + // cause — e.g. refresh reissued the leader's own `rejected` bytes + // and `finish()` returned `RefreshRejected`. That failure is valid + // only for the leader's specific rejected token; a joiner with a + // *different* `rejected` (or none) should rerun: its refresh may + // yield a valid token. The leader publishes its rejected-token + // SHA-256 digest so joiners can compare without inspecting the + // token bytes directly. A digest mismatch triggers an `acquire_leader` + // rerun (the slot is already evicted). A false rerun (non-rejection + // failure with digest mismatch) costs one network round-trip and + // stays headless — far better than silently adopting a wrong denial. + // + // * It may publish a terminal failure even though a sibling wrote + // a valid replacement into the cache while we waited. We + // re-check the cache cheaply before adopting the failure — a + // lock-free disk read, never a browser or refresh — so a shared + // failure can never fan out into an N-way browser storm. The + // disk read is lock-free (`usable_from_disk`, not under `state`) + // because all waiters wake together and the in-memory memo is + // not load-bearing here — the next real acquisition re-reads and + // adopts under the lock. + let (leader_rejected_digest, outcome) = slot.wait().await; + match outcome { + Ok(token) if Some(token.access_token.as_str()) != rejected => { + // Conditionally reconcile this source's own credential + // state so a subsequent plain `bearer()` on this source + // returns the newly-acquired token rather than a stale or + // absent credential. Adopt when B's state is absent, + // expired, or still pointing at B's own rejected token. + // Preserve a distinct newer usable credential — if another + // task independently installed a valid token into B's state + // between B joining and B waking, that token is better than + // the shared result and must not be overwritten. + // + // `lock().await` rather than `try_lock`: the reconciliation + // must complete before returning. The joiner holds neither + // the INFLIGHT registry mutex nor the cross-process file + // lock at this point, so awaiting `state` cannot deadlock + // and skipping the write would leave stale or empty state, + // recreating the original P1 regression on the next plain + // `bearer()` call. + { + let mut state = self.state.lock().await; + let adopt = state.as_ref().is_none_or(|cur| { + is_expired(cur) || rejected.is_some_and(|rej| cur.access_token == rej) + }); + if adopt { + *state = Some(token.clone()); + } + } + return Ok(token.access_token); + } + Ok(_) => { + return self + .acquire_leader(intent, rejected) + .await + .map(|t| t.access_token); + } + Err(shared) => { + // Reject-digest mismatch: the leader's failure was + // rejection-relative to ITS OWN `rejected` token, not ours. + // Rerun so we can pursue our own refresh/browser path. + if leader_rejected_digest != digest_of(rejected) { + return self + .acquire_leader(intent, rejected) + .await + .map(|t| t.access_token); + } + // Neutralize B's matching rejected in-memory state so a + // subsequent plain `bearer()` on this source does not + // resurface the rejected credential. + // + // `lock().await` rather than `try_lock`: expiry must + // complete before returning. The joiner holds neither the + // INFLIGHT registry mutex nor the cross-process file lock + // here, so awaiting `state` cannot deadlock. Skipping the + // expiry would leave matching rejected X live, recreating + // the original P1 regression on the next plain `bearer()`. + // + // In-memory only (`expire_rejected_memory`, not + // `expire_rejected`): the leader already ran the durable + // disk invalidation under the file lock. Re-running disk + // writes here is lockless — process C may have persisted a + // valid replacement under the same lock between A's failure + // and this rename, and B's unfenced rename would overwrite + // it. Note: a subsequent plain `bearer()` (`rejected=None`) + // calls `cached_hit` before the cross-process lock and can + // therefore re-read the disk copy without acquiring the lock. + { + let mut state = self.state.lock().await; + self.expire_rejected_memory(&mut state, rejected); + } + if let Some(hit) = self.usable_from_disk(rejected) { + return Ok(hit); + } + return Err(shared); } } } - // 5. No usable cache: full browser dance. - let fresh = browser_pkce_flow(&self.http, &self.cfg, &endpoints).await?; - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - Ok(bearer) + // Leader: run the real flow, then evict + publish. The guard makes + // eviction and joiner wake-up happen even if this future is cancelled + // or panics, so a dropped leader can never wedge its joiners or leave a + // dead slot that turns later callers into joiners of nothing. + let guard = LeaderGuard::new(key, slot); + let result = self.acquire_leader(intent, rejected).await; + guard.complete(result, digest_of(rejected)) } - async fn bearer_no_browser(&self) -> Result { - self.try_bearer_no_browser().await + /// The leader's slow-path body: take the cross-process lock, then run the + /// bounded acquisition under it. Split out so [`acquire`] can wrap it in + /// the in-process single-flight without the lock/deadline logic bleeding + /// into the joiner path. + async fn acquire_leader( + &self, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + // Snapshot the current attempt generation *before* queueing on the + // lock. When we acquire the lock, we compare: if the generation + // advanced, a predecessor completed while we were waiting and we can + // adopt its outcome instead of re-running the full flow. + let attempt_path = self.attempt_path(); + let snapshot_gen = read_attempt(&attempt_path) + .map(|r| r.generation) + .unwrap_or(0); + // Observability hook: cross-process tests install a tracing layer that + // watches for this event to establish deterministic ordering — it fires + // after the snapshot is taken and before the process queues on the lock. + tracing::trace!( + target: "buzz_agent::auth::acquire_leader_snapshot", + snapshot_gen, + "snapshot taken" + ); + + // Slow path: one flow at a time per cache key. The waiter's deadline + // exceeds a healthy holder's attempt deadline, so it never gives up on + // a live holder. + let deadline = std::time::Instant::now() + LOCK_WAIT_TIMEOUT; + let _guard = acquire_auth_lock(&self.lock_path(), deadline).await?; + + // Bound the whole locked attempt so a wedged flow can't hold the lock + // past the waiters' patience. The deadline is passed *into* + // `acquire_locked` rather than wrapped around it in a cancelling + // `tokio::time::timeout`: a cancel drops the future at an arbitrary + // await point, which would skip the cooldown write for a timed-out + // interactive attempt and let the next `Auto` caller re-pop a browser. + // Threading the deadline lets every interactive timeout exit through + // the common outcome writer while the lock is still held. + let attempt_deadline = std::time::Instant::now() + AUTH_ATTEMPT_DEADLINE; + self.acquire_locked( + intent, + rejected, + attempt_deadline, + &attempt_path, + snapshot_gen, + ) + .await } - /// Force-refresh after a 401, never touching the browser flow. + /// Slow-path body, run while holding the cross-process auth lock. /// - /// `rejected` is the access token the server just 401'd. Coalescing keys - /// off token *identity*, not the expiry clock: a 401 means the token was - /// rejected while it still looked locally fresh, so `is_expired()` would - /// say "keep it" and no grant would ever run. Instead, under the lock we - /// compare the current cached token to `rejected` — if they differ, a - /// concurrent caller (this process or a sibling) already refreshed, so we - /// return the new token without burning a second grant. If they still - /// match, this is the rejected token and we run the refresh-token grant - /// unconditionally. The whole check→refresh→save runs under one lock hold - /// so concurrent callers serialize. On any failure the refresh token is - /// preserved (never nulled) and the error is terminal `LlmAuth` — no - /// browser, no hang. - async fn refresh_now(&self, rejected: &str) -> Result { + /// `attempt_deadline` bounds the whole locked flow. Discovery and refresh + /// are each bounded by the HTTP client's per-request timeout; the browser + /// flow is wrapped in the *remaining* budget so a total-deadline expiry + /// during the interactive step surfaces as [`AuthError::TimedOut`] through + /// the same arm that records the cooldown — never as a cancellation that + /// drops the guard without writing it. + /// + /// `attempt_path` + `snapshot_gen` implement cross-process failure + /// single-flight: the caller snapshotted `snapshot_gen` before queueing on + /// the lock; if the generation has since advanced, a predecessor completed + /// while we waited. A caller already queued when the predecessor ran adopts + /// its same-intent terminal failure rather than re-running — including + /// `UserInitiated` callers, mirroring what [`INFLIGHT`] does within one + /// process. A `UserInitiated` caller arriving *after* the failure snapshots + /// the new generation and naturally does not adopt. + async fn acquire_locked( + &self, + intent: AuthIntent, + rejected: Option<&str>, + attempt_deadline: std::time::Instant, + attempt_path: &Path, + snapshot_gen: u64, + ) -> Result { let mut state = self.state.lock().await; - // 1. Coalesce by identity: if the cached token (in-memory, then disk) - // is no longer the one the server rejected, someone already - // refreshed it. Return that instead of grabbing another grant. - if let Some(tok) = state.as_ref() { - if tok.access_token != rejected { - return Ok(tok.access_token.clone()); - } + // A 401 (`rejected = Some`) proves the cached access token is dead even + // though its local expiry clock still looks fresh. Neutralize it now, + // under the lock, so it can never be served again: cache_hit already + // excludes it for callers carrying `rejected`, but a later plain + // `bearer()` (`rejected = None`) or a freshly constructed source would + // otherwise trust the clock and hand back the proven-dead bytes. The + // refresh token is untouched — it was not rejected and drives the + // recovery below. + self.expire_rejected(&mut state, rejected); + + // Re-check under the lock: a holder we queued behind may have already + // produced a token (this process or a sibling wrote the cache). + if self.cached_hit(&mut state, rejected).is_some() { + // `cached_hit` guarantees state is populated on a hit (memory entry + // was already there, or disk token was adopted into state). + return Ok(state.clone().expect("cached_hit confirmed token in state")); } - if let Some(disk_tok) = read_cache(&self.cache_path) { - if disk_tok.access_token != rejected { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + + // Cross-process failure single-flight. A predecessor completed while + // this caller was waiting on the lock: check whether its outcome was a + // terminal failure we should adopt rather than re-run. The contract is + // *temporal*, not intent-based: a caller whose pre-queue snapshot is + // older than the current generation was already queued while the + // predecessor ran and may adopt its failure, mirroring how the + // in-process [`INFLIGHT`] registry coalesces same-intent callers + // (including `UserInitiated`) within a single process. A `UserInitiated` + // caller arriving *after* a failure naturally snapshots the new + // generation and does not adopt, so "later explicit user retry bypasses" + // falls out without a special case. The conditions are: + // (a) the attempt generation advanced past our snapshot — we were + // queued while the predecessor ran, not a fresh arrival after it; + // (b) the recorded intent matches ours — cross-process adoption + // respects the same (path, intent) boundary as INFLIGHT, so a + // `UserInitiated` waiter never inherits an `Auto`/`Headless` + // failure (different intent, different promise to the user); + // (c) the recorded result is a recognized terminal failure — `"ok"` + // and unrecognized codes fall through to a normal attempt; + // (d) the recorded rejected_digest matches ours — a failure caused by + // the predecessor's specific rejected token is not valid for a + // caller with a *different* rejected token (both-`None` matches). + // A digest mismatch triggers a normal attempt; a false rerun on a + // non-rejection-relative failure costs one network round-trip and + // stays headless — preferable to silently serving a wrong denial. + // + // Adoptors do NOT write a new attempt record: adopting does not + // represent new work. Writing one would advance the generation so a + // third caller that arrives after the adoption (snapshot = new gen) sees + // no advance and tries its own attempt — but a fourth arriving while the + // third runs would inherit the adopter's re-written record, relaying the + // original failure indefinitely. The original record already has the + // correct generation; subsequent waiters with snapshot < original gen + // still adopt from it directly. + if let Some(rec) = read_attempt(attempt_path) { + if rec.generation > snapshot_gen + && rec.intent == intent.as_str() + && rec.rejected_digest == digest_of(rejected) + { + if let Some(err) = AuthError::from_code(&rec.result) { + return Err(err); + } } } - // 2. The cached token is still the rejected one. Run the refresh-token - // grant unconditionally — the expiry clock can't be trusted here, a - // locally-fresh token is exactly what got 401'd. - let refresh = state.as_ref().and_then(|t| t.refresh_token.clone()); - let Some(rt) = refresh else { - return Err(AgentError::LlmAuth( - "token rejected and no refresh token available".into(), - )); - }; - let endpoints = self.endpoints().await?; - match self.refresh(&endpoints, &rt).await { - Ok(fresh) => { - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - Ok(bearer) + // Refresh-token grant, if we have one. Endpoints are discovered lazily + // here (and reused by the browser branch) so a no-refresh headless + // failure never depends on reaching the discovery URL. + let mut endpoints: Option = None; + let mut refresh_failed = false; + if let Some(rt) = state.as_ref().and_then(|t| t.refresh_token.clone()) { + let eps = self.discover(&mut endpoints).await?; + match self.refresh(eps, &rt).await { + RefreshOutcome::Refreshed(fresh) => { + let result = self.finish(&mut state, fresh, intent, rejected); + // Record recognized terminal failures (rejected-equal reissuance) + // so a cross-process headless waiter can adopt them rather than + // re-running the same dead refresh. Successes are shared through + // the token cache — a waiter that wins the lock after us finds + // the token via `cached_hit` without reaching the adoption check. + if let Err(ref e) = result { + write_attempt(attempt_path, intent, e.code(), rejected); + } + return result; + } + // A transient fault (transport/timeout/5xx/decode) is not a + // credential decision: never fall through to a browser or + // report RefreshRejected. A sibling may have written a fresh + // token while we ran, so honor that first; otherwise this is + // infrastructural and surfaces as NetworkUnavailable. + RefreshOutcome::Network => { + if self.cached_hit(&mut state, rejected).is_some() { + return Ok(state.clone().expect("cached_hit confirmed token in state")); + } + return Err(AuthError::NetworkUnavailable); + } + // The token endpoint rejected the grant: a dead refresh token. + // A sibling may still have won the race while we ran; if not, + // fall through to a browser (interactive) or RefreshRejected + // (headless). + RefreshOutcome::Rejected => { + if self.cached_hit(&mut state, rejected).is_some() { + return Ok(state.clone().expect("cached_hit confirmed token in state")); + } + refresh_failed = true; + } } - // 3. Refresh token is itself dead. Terminal — surfacing LlmAuth - // stops the retry loop instead of falling to the browser flow, - // which would hang a headless harness. - Err(e) => Err(AgentError::LlmAuth(format!("token refresh failed: {e}"))), } - } -} -impl PkceOAuthTokenSource { - /// Return a bearer token from cache or refresh, **never** opening a browser. - /// - /// Follows the same steps as [`bearer`](TokenSource::bearer) but stops at - /// step 4 — if no usable token is available after cache + refresh attempts, - /// returns `Err(LlmAuth(...))` instead of launching the browser PKCE flow. - /// Used by model-discovery paths that must not block on user interaction. - pub(crate) async fn try_bearer_no_browser(&self) -> Result { - let mut state = self.state.lock().await; - - // 1. In-memory cache hit, still fresh. - if let Some(tok) = state.as_ref() { - if !is_expired(tok) { - return Ok(tok.access_token.clone()); - } + // No token from cache or refresh. Browser or terminal failure. + if !intent.may_open_browser() { + let err = if refresh_failed { + AuthError::RefreshRejected + } else { + AuthError::NoCredential + }; + write_attempt(attempt_path, intent, err.code(), rejected); + return Err(err); } - // 2. Re-read disk — another process may have refreshed already. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + let cooldown_path = self.cooldown_path(); + if intent.honors_cooldown() { + // A recent browser attempt failed; surface its recorded outcome + // instead of re-popping a browser on this automatic attempt. + if let Some(recorded) = read_cooldown(&cooldown_path) { + return Err(recorded); } + } else { + // An explicit user retry clears any prior suppression. + clear_cooldown(&cooldown_path); } - // 3. Try refresh if we have a refresh token. Endpoints are discovered - // lazily here — only when a refresh token is actually present — so - // that an unreachable OIDC discovery URL cannot prevent the - // no-token/no-cache path from returning `LlmAuth` (graceful - // fallback) instead of `Llm` (hard error). - let refresh = state.as_ref().and_then(|t| t.refresh_token.clone()); - if let Some(rt) = refresh { - let endpoints = self.endpoints().await?; - match self.refresh(&endpoints, &rt).await { - Ok(fresh) => { - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - return Ok(bearer); - } - Err(e) => { - tracing::warn!(error = %e, "oauth refresh failed during model discovery"); - } + let eps = self.discover(&mut endpoints).await?; + // Wrap the browser flow in the *remaining* attempt budget so the total + // locked time never exceeds `attempt_deadline` (and thus never + // outlasts a waiter's `LOCK_WAIT_TIMEOUT`). A deadline expiry maps to + // `TimedOut`, which is cooldown-worthy, so it flows through the same + // writer arm below instead of being dropped by a cancel that would + // release the lock without recording the cooldown. + let remaining = attempt_deadline.saturating_duration_since(std::time::Instant::now()); + let flow = browser_pkce_flow(&self.http, &self.cfg, eps, self.opener.as_ref()); + let outcome = match tokio::time::timeout(remaining, flow).await { + Ok(result) => result, + Err(_) => Err(AuthError::TimedOut), + }; + match outcome { + // `finish` clears the cooldown on success and rejects a re-issued + // 401'd token before persisting it. + Ok(fresh) => { + let result = self.finish(&mut state, fresh, intent, rejected); + let code = match &result { + Ok(_) => "ok", + Err(e) => e.code(), + }; + write_attempt(attempt_path, intent, code, rejected); + result } - - // 4. Re-read disk after refresh failure. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + Err(e) => { + if e.is_cooldown_worthy() { + write_cooldown(&cooldown_path, &e); } + write_attempt(attempt_path, intent, e.code(), rejected); + Err(e) } } + } - // No usable token — return error instead of opening a browser. - Err(AgentError::LlmAuth( - "no cached Databricks token; run `buzz-agent auth databricks` first".into(), - )) + /// Persist a freshly-obtained token, clear any cooldown, and return the + /// full [`CachedToken`] on success. A cache-write failure maps to + /// [`AuthError::NetworkUnavailable`] (the infrastructural bucket) — the + /// token was valid but couldn't be persisted, which the caller should treat + /// as transient, not as a credential rejection. + /// + /// The candidate-token persistence boundary for refresh and browser results. + /// Cache-hit paths bypass this function, but every refresh- or browser-issued + /// token flows through here before being written to memory or disk. This is + /// where the 401-recovery invariant is enforced: a token equal to the + /// caller's `rejected` bytes must never be committed — doing so would cache + /// the proven-dead token as fresh, so a later plain `bearer()` (`rejected = + /// None`) or a freshly constructed source reading the same cache would serve + /// it back. Validating *before* the write keeps the dead token out of the + /// cache and off disk entirely: we fail typed (`NetworkUnavailable` interactive + /// / `RefreshRejected` headless) without caching it or clearing the cooldown. + /// `cached_hit` and `usable_from_disk` already exclude `rejected`, so guarding + /// the two live-token sites (refresh and browser exchange) here covers every + /// path that can produce the rejected bytes. + /// + /// Returning the full [`CachedToken`] (rather than just the bearer string) + /// lets `acquire_locked` → `acquire_leader` propagate it all the way to + /// [`LeaderGuard::complete`], which publishes it through the [`InflightSlot`] + /// so every joiner can reconcile its own independent `state` cell. + fn finish( + &self, + state: &mut Option, + token: CachedToken, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + if rejected == Some(token.access_token.as_str()) { + return Err(if intent.may_open_browser() { + AuthError::NetworkUnavailable + } else { + AuthError::RefreshRejected + }); + } + self.save(state, token.clone()) + .map_err(|_| AuthError::NetworkUnavailable)?; + clear_cooldown(&self.cooldown_path()); + Ok(token) + } +} + +#[async_trait] +impl TokenSource for PkceOAuthTokenSource { + /// Acquire a bearer for a request. Routes through the coordinator as a + /// [`Headless`](AuthIntent::Headless) acquisition: it serves a cached or + /// refreshed token but never opens a browser, so a managed runtime with no + /// interactive display can never hang on inference. First-use auth is the + /// job of `buzz-agent auth databricks` ([`interactive_login`]). + /// + /// [`interactive_login`]: PkceOAuthTokenSource::interactive_login + async fn bearer(&self) -> Result { + self.acquire(AuthIntent::Headless, None) + .await + .map_err(Into::into) + } + + /// Identical to [`bearer`](Self::bearer) for this source — both are + /// headless. Retained as a distinct method so callers can state the + /// no-browser requirement at the call site (and so other [`TokenSource`] + /// impls that *would* browse in `bearer` can still expose a safe path). + async fn bearer_no_browser(&self) -> Result { + self.acquire(AuthIntent::Headless, None) + .await + .map_err(Into::into) + } + + /// Force a fresh bearer after the server rejected `rejected` with a 401. + /// + /// A [`Headless`](AuthIntent::Headless) acquisition keyed by token + /// *identity* rather than the expiry clock: a 401 means the cached token + /// was rejected while still locally fresh, so [`is_expired`] would wrongly + /// keep it. Passing `rejected` makes the coordinator run the refresh-token + /// grant unless a concurrent caller already replaced the token, in which + /// case that newer token is returned without a second grant. Never opens a + /// browser; a dead refresh token surfaces terminally so the retry loop + /// stops instead of hanging. + async fn refresh_now(&self, rejected: &str) -> Result { + self.acquire(AuthIntent::Headless, Some(rejected)) + .await + .map_err(Into::into) } } // ---- helpers ------------------------------------------------------------- +/// SHA-256 hex digest of `rejected` token bytes, or `None` when there is no +/// rejected token. Used to scope in-process and cross-process failure adoption +/// to the specific token that was rejected — a joiner carrying a *different* +/// rejected token (or none) must not inherit a rejection-relative failure. +fn digest_of(rejected: Option<&str>) -> Option { + rejected.map(|r| hex::encode(sha2::Sha256::digest(r.as_bytes()))) +} + /// Aborts a spawned task when dropped. Used to guarantee the localhost /// callback server doesn't outlive a failed/abandoned PKCE attempt. struct AbortOnDrop(tokio::task::JoinHandle<()>); @@ -437,11 +1350,30 @@ fn is_expired(t: &CachedToken) -> bool { let Some(exp) = t.expires_at else { return false; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - now + TOKEN_REFRESH_LEEWAY.as_secs() >= exp + now_secs() + TOKEN_REFRESH_LEEWAY.as_secs() >= exp +} + +const BUZZ_AGENT_CONFIG_DIR_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + +fn oauth_cache_root_for( + config_override: Option, + home_dir: Option, +) -> Result { + if let Some(root) = config_override { + return Ok(root.join("buzz-agent").join("oauth")); + } + Ok(home_dir + .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? + .join(".config") + .join("buzz-agent") + .join("oauth")) +} + +fn default_oauth_cache_root() -> Result { + oauth_cache_root_for( + std::env::var_os(BUZZ_AGENT_CONFIG_DIR_ENV).map(PathBuf::from), + dirs::home_dir(), + ) } fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { @@ -455,16 +1387,384 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let dir = match &cfg.cache_dir_override { Some(p) => p.join(&cfg.cache_namespace), - None => dirs::home_dir() - .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? - .join(".config") - .join("buzz-agent") - .join("oauth") - .join(&cfg.cache_namespace), + None => default_oauth_cache_root()?.join(&cfg.cache_namespace), }; Ok(dir.join(format!("{hash}.json"))) } +/// Append `ext` as an extra extension onto `base` (e.g. `.json` → +/// `.json.lock`). Keeps the lock and cooldown sidecars in the same +/// per-key directory as the cache, so they inherit its `$HOME` override and +/// owner-only parent without a second key derivation. +fn append_ext(base: &Path, ext: &str) -> PathBuf { + let mut name = base.as_os_str().to_owned(); + name.push("."); + name.push(ext); + PathBuf::from(name) +} + +/// Durable record of the last browser-attempt failure for a cache key. Written +/// while holding the auth lock so concurrent writers can't interleave, read by +/// `Auto` callers to decide whether to suppress an automatic browser re-launch. +#[derive(Debug, Serialize, Deserialize)] +struct CooldownRecord { + /// [`AuthError::code`] of the failure being cooled down. + code: String, + /// Unix seconds after which the cooldown lapses and an `Auto` caller may + /// launch a browser again. + until: u64, +} + +/// Durable record of the generation and outcome of the most recently completed +/// slow-path acquisition attempt for a cache key. +/// +/// Cross-process single-flight for *failures*: the in-process [`INFLIGHT`] +/// registry coalesces same-key callers within one process, but two separate +/// processes both waiting on the OS file lock do NOT share the registry. When +/// process A holds the lock and fails (e.g. browser denial or dead refresh), +/// process B's queued caller acquires the lock after A releases it and — under +/// the old protocol — would re-run the full flow from scratch. This record lets +/// B detect that it was already queued while A ran and adopt A's failure +/// instead of hammering the provider again. +/// +/// Protocol: +/// - A caller **snapshots** the current generation from the sidecar *before* +/// queueing on the file lock. +/// - A caller that **acquires** the lock compares the current generation to its +/// snapshot: if it advanced, a predecessor completed while it was waiting. +/// If the recorded intent matches this caller's intent and the outcome is a +/// recognized terminal failure, adopt it rather than re-running. +/// - Completing attempts **write** a fresh record under the lock. Write +/// coverage: the headless no-browser arm (`RefreshRejected`/`NoCredential`), +/// the refresh arm when `finish()` fails typed (rejected-equal reissuance), +/// and the browser arm (all outcomes including `"ok"`). Omissions that are +/// intentionally not adoption-worthy: transient `Network` errors, discovery +/// failures (both non-terminal; next caller retries), and cache/refresh- +/// success paths (a waiting caller finds the token via `cached_hit` without +/// reaching the adoption check). +/// +/// The generation counter is read fresh from disk at write time so each +/// completed attempt strictly advances the value regardless of when the +/// caller's pre-queue snapshot was taken. +/// +/// Intent matching is same-intent only, mirroring the in-process `(path, +/// intent)` key. The temporal condition handles "later explicit retry bypasses": +/// a `UserInitiated` caller arriving after the failure snapshots the new +/// generation and sees no advance, so it always runs its own attempt and never +/// inherits a prior failure — regardless of intent. +#[derive(Debug, Serialize, Deserialize)] +struct AttemptRecord { + /// Strictly increasing counter: read from disk at write time and incremented + /// by one so each attempt advances from the actual current value regardless + /// of when the writing caller's snapshot was taken. + generation: u64, + /// Intent of the attempt that completed, as [`AuthIntent::as_str`]. + intent: String, + /// Error code of the terminal failure, or `"ok"` on success. Matches + /// [`AuthError::code`] / the `"ok"` sentinel. + result: String, + /// SHA-256 hex digest of the token bytes that the completing caller had + /// marked as `rejected`, or `None` when the caller carried no rejected + /// token. A waiter adopts only when its own digest matches: a failure caused + /// by the leader's specific rejected token is not valid for a waiter with a + /// *different* rejected token (or none) — its refresh may yield a live + /// token. Both-`None` is a match. A mismatched digest triggers a normal + /// attempt; a false rerun on a non-rejection failure costs one network round- + /// trip and stays headless — preferable to silently adopting a wrong denial. + #[serde(default)] + rejected_digest: Option, +} + +/// Read the attempt sidecar at `path`, if any. Returns `None` when absent, +/// unparseable, or the generation is 0 (no attempt has completed yet). +fn read_attempt(path: &Path) -> Option { + let body = fs::read(path).ok()?; + let record: AttemptRecord = serde_json::from_slice(&body).ok()?; + Some(record) +} + +/// Write a fresh attempt record at `path`. Called under the auth lock. +/// Best-effort — a write failure only means the next cross-process waiter +/// cannot adopt this attempt's outcome, so errors are swallowed. +/// +/// Always reads the current on-disk generation before writing so the new +/// record strictly advances from the actual last-recorded value, not from +/// any caller's pre-queue snapshot. An intervening different-intent attempt +/// that advanced the sidecar between snapshot and lock-acquire is reflected +/// correctly: the next waiter's comparison still sees a real advance. +fn write_attempt(path: &Path, intent: AuthIntent, result: &str, rejected: Option<&str>) { + let current_gen = read_attempt(path).map_or(0, |r| r.generation); + let record = AttemptRecord { + generation: current_gen.wrapping_add(1), + intent: intent.as_str().to_owned(), + result: result.to_owned(), + rejected_digest: digest_of(rejected), + }; + if let Ok(body) = serde_json::to_vec(&record) { + let _ = write_private_cache(path, &body); + } +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Return the still-active cooldown outcome for `path`, if any. +/// +/// `None` when the sidecar is absent, unparseable, expired, or records a code +/// this build doesn't recognize — every one of those means "no active +/// cooldown", so the caller proceeds to a normal attempt. An expired record is +/// removed opportunistically so the directory doesn't accumulate stale files. +fn read_cooldown(path: &Path) -> Option { + let body = fs::read(path).ok()?; + let record: CooldownRecord = serde_json::from_slice(&body).ok()?; + if record.until > now_secs() { + AuthError::from_code(&record.code) + } else { + let _ = fs::remove_file(path); + None + } +} + +/// Record `err` as a fresh cooldown at `path`, expiring [`COOLDOWN_DURATION`] +/// from now. Best-effort: a write failure only means the next automatic +/// attempt may re-pop a browser, never a hard auth failure, so errors are +/// swallowed. Called while holding the auth lock. +fn write_cooldown(path: &Path, err: &AuthError) { + let record = CooldownRecord { + code: err.code().to_string(), + until: now_secs() + COOLDOWN_DURATION.as_secs(), + }; + if let Ok(body) = serde_json::to_vec(&record) { + let _ = write_private_cache(path, &body); + } +} + +/// Remove any cooldown sidecar at `path`. Called on a successful acquisition +/// (the problem is resolved) and by `UserInitiated` callers that bypass the +/// cooldown (an explicit retry clears the suppression). Best-effort. +fn clear_cooldown(path: &Path) { + let _ = fs::remove_file(path); +} + +/// Hold on the cross-process auth lock. Dropping it (or the owning process +/// dying) releases the OS advisory lock — no PID files, no manual break. +#[derive(Debug)] +struct AuthLockGuard(fs::File); + +impl Drop for AuthLockGuard { + fn drop(&mut self) { + // Explicit for intent; closing the fd would release it regardless. + let _ = FileExt::unlock(&self.0); + } +} + +/// Acquire the cross-process auth lock at `path`, polling until `deadline`. +/// +/// `fs2::FileExt::try_lock_exclusive` maps to `flock(LOCK_EX | LOCK_NB)` on +/// Unix and `LockFileEx` on Windows — advisory, per–open-file-description, so +/// a lock taken on one handle blocks every other handle (same process or not), +/// which is exactly the cross-process single-flight guarantee we want. The +/// try-lock is non-blocking, so we poll on [`LOCK_POLL_INTERVAL`] rather than +/// parking a worker thread in a blocking `lock_exclusive()`. Contention is +/// reported as [`fs2::lock_contended_error`] (`EWOULDBLOCK`/`EACCES` on Unix, +/// `ERROR_LOCK_VIOLATION` on Windows); we match its `raw_os_error` and retry. +/// Any other error is a real fault and returns [`AuthError::LockTimeout`]. A +/// waiter whose `deadline` lapses also returns [`AuthError::LockTimeout`]; +/// because the caller sets that deadline longer than [`AUTH_ATTEMPT_DEADLINE`], +/// a healthy holder always finishes first. +async fn acquire_auth_lock( + path: &Path, + deadline: std::time::Instant, +) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| AuthError::LockTimeout)?; + } + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(|_| AuthError::LockTimeout)?; + let contended = fs2::lock_contended_error().raw_os_error(); + loop { + match file.try_lock_exclusive() { + Ok(()) => return Ok(AuthLockGuard(file)), + Err(e) if e.raw_os_error() == contended => { + if std::time::Instant::now() >= deadline { + return Err(AuthError::LockTimeout); + } + tokio::time::sleep(LOCK_POLL_INTERVAL).await; + } + Err(_) => return Err(AuthError::LockTimeout), + } + } +} + +/// Key for the in-process single-flight registry: the cross-process lock path +/// (one per cache key) paired with the caller's [`AuthIntent`]. Keying by the +/// full intent — not merely browser capability — keeps callers with *different* +/// outcome policy from coalescing: an `Auto` leader honors a live cooldown and +/// returns its recorded `Denied`/`TimedOut`, but a `UserInitiated` caller is +/// promised a cooldown bypass and a fresh browser, so it must never inherit an +/// `Auto` leader's suppressed result. Each intent still coalesces with itself +/// (two concurrent `UserInitiated` sign-ins share one browser), and all intents +/// on the same key still serialize through the cross-process file lock. +type InflightKey = (PathBuf, AuthIntent); + +/// Process-global registry of in-flight auth attempts, the in-process +/// counterpart to [`acquire_auth_lock`]'s cross-process file lock. The file +/// lock serializes work across processes and shares *success* via a cache +/// re-read, but a queued caller that acquires the lock after a browser denial +/// would clear the sidecar and pop a second browser. This registry closes that +/// gap: a caller that arrives while a leader's attempt is in flight joins the +/// leader's [`InflightSlot`] and receives the *same* result — success or +/// failure — instead of taking the lock afterward and launching again. Guarded +/// by a `std::sync::Mutex` because every critical section is a cheap map lookup +/// with no `.await` held. +static INFLIGHT: LazyLock>>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +/// Lock the in-flight registry, recovering from a poisoned mutex rather than +/// panicking: the only work done under this lock is map lookups that can't +/// leave inconsistent state, so a poison from an unrelated panic must not wedge +/// every future auth attempt. +fn inflight_registry() -> std::sync::MutexGuard<'static, HashMap>> { + INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The value published by a leader to its joiners: the leader's rejected-token +/// SHA-256 digest (non-secret identity, `None` when the leader carried no +/// `rejected`) paired with the attempt result. Joiners use the digest to detect +/// a mismatch — the leader's rejection-relative failure is not valid for a +/// joiner that carried a *different* rejected token. +/// +/// On success the full [`CachedToken`] is published so each joiner can +/// conditionally reconcile its own independent [`PkceOAuthTokenSource::state`] +/// cell. Publishing the full credential (not just the bearer string) prevents +/// a joining source's state from remaining stale or empty after the coalesced +/// flow, which would otherwise cause a subsequent plain `bearer()` on that +/// source to resurface a rejected or absent credential rather than the +/// newly-acquired one. +type SlotPublish = (Option, Result); + +/// The shared result of one leader's auth attempt, awaited by any joiner that +/// arrived while the leader was in flight. A `watch` channel gives us +/// publish-once plus wait-for-publish in one primitive: the leader publishes +/// exactly once through [`LeaderGuard`]; joiners clone the published result. +struct InflightSlot { + tx: watch::Sender>, + rx: watch::Receiver>, +} + +impl InflightSlot { + fn new() -> Self { + let (tx, rx) = watch::channel(None); + Self { tx, rx } + } + + /// Block until the leader publishes, then clone out `(rejected_digest, result)`. + /// + /// `borrow_and_update` marks the current value seen before awaiting, so a + /// publish that lands between the read and the `changed()` await is not a + /// lost wakeup — the version has advanced, so `changed()` returns at once. + /// A closed channel (leader dropped without publishing — which + /// [`LeaderGuard`]'s `Drop` prevents) surfaces as a transient so the caller + /// retries rather than hangs. + async fn wait(&self) -> SlotPublish { + let mut rx = self.rx.clone(); + loop { + if let Some(publish) = rx.borrow_and_update().clone() { + return publish; + } + if rx.changed().await.is_err() { + return (None, Err(AuthError::NetworkUnavailable)); + } + } + } + + /// Publish `(rejected_digest, result)` to every waiting joiner. A send + /// error means no joiners remain, which is fine. + fn publish(&self, rejected_digest: Option, result: Result) { + let _ = self.tx.send(Some((rejected_digest, result))); + } +} + +/// RAII owner of a leader's in-flight slot. Guarantees the slot is evicted from +/// [`INFLIGHT`] and a result published to joiners even if the leader future is +/// cancelled or panics: a leader that skipped this would leave a dead slot that +/// turns every later caller into a joiner of an attempt that never publishes, +/// wedging them until `LOCK_WAIT_TIMEOUT`. +struct LeaderGuard { + key: InflightKey, + slot: Arc, + done: bool, +} + +impl LeaderGuard { + fn new(key: InflightKey, slot: Arc) -> Self { + Self { + key, + slot, + done: false, + } + } + + /// Normal completion: evict the slot, publish `(rejected_digest, result)` + /// to joiners, and return the bearer to the leader. The full + /// [`CachedToken`] is published so joiners can reconcile their own + /// [`PkceOAuthTokenSource::state`] before returning. Evicting *before* + /// publishing means a caller arriving after this point starts a fresh + /// attempt (a later explicit retry may launch), while joiners already + /// holding the slot still receive the result. `Drop` covers the cancel/panic + /// path. + fn complete( + mut self, + result: Result, + rejected_digest: Option, + ) -> Result { + self.done = true; + Self::evict(&self.key, &self.slot); + // Clone the error before moving `result` into the slot publish so we + // can return the original error to the leader on failure. + let leader_return = result + .as_ref() + .map(|t| t.access_token.clone()) + .map_err(|e| e.clone()); + self.slot.publish(rejected_digest, result); + leader_return + } + + /// Remove this leader's slot from the registry, but only if it is still the + /// same slot — defends against evicting a successor a later attempt may + /// have installed under the same key. + fn evict(key: &InflightKey, slot: &Arc) { + let mut reg = inflight_registry(); + if reg + .get(key) + .is_some_and(|existing| Arc::ptr_eq(existing, slot)) + { + reg.remove(key); + } + } +} + +impl Drop for LeaderGuard { + fn drop(&mut self) { + if self.done { + return; + } + // Cancelled or panicked before `complete`: evict so later callers start + // fresh, and wake joiners with a transient error so they retry rather + // than hang on a leader that will never publish. + Self::evict(&self.key, &self.slot); + self.slot.publish(None, Err(AuthError::NetworkUnavailable)); + } +} + /// Load a cached token, enforcing the owner-only invariant on load. /// /// Owner-only permissions are a cache *lifecycle* invariant, not just a @@ -517,11 +1817,23 @@ fn read_private_cache(path: &Path) -> io::Result> { Ok(body) } -/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the -/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +/// Non-Unix: token persistence and reading are both disabled until a +/// Windows-specific owner-only DACL is implemented. Any legacy token file +/// left by an older build (written with default ACLs) is deleted +/// opportunistically so the exposed artifact cannot be served by new builds. +/// Returns an error so [`read_cache`] yields `None`, giving a consistent +/// memory-only cache on non-Unix. #[cfg(not(unix))] fn read_private_cache(path: &Path) -> io::Result> { - fs::read(path) + // Best-effort removal of any legacy file. Errors are ignored — either the + // file does not exist (normal case) or it cannot be removed (no worse + // than before — the DACL story is still broken, but that is the pre-fix + // state we are trying to retire). + let _ = fs::remove_file(path); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "token disk cache disabled on non-Unix (no owner-only DACL)", + )) } /// Removes a temp file on drop unless it was already renamed away. Keeps a @@ -712,21 +2024,38 @@ fn sanitize_callback_detail(raw: &str) -> String { .collect() } -/// Spin up a localhost callback server, open the authorize URL in a -/// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then -/// exchange the code for a token. +/// Spin up a localhost callback server, hand the authorize URL to `opener`, +/// wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then exchange the +/// code for a token. +/// +/// `opener` is invoked *after* the listener is bound and the abort guard is +/// armed, so a launch failure never returns a URL pointing at a torn-down +/// listener. Every failure is a typed [`AuthError`] so the coordinator can +/// record a cooldown (or not) by category: an open failure is +/// [`BrowserOpenFailed`], a redirect that never arrives is [`TimedOut`], a +/// provider-reported denial is [`Denied`], and a code exchange the provider +/// rejects with `invalid_grant` is [`ExchangeFailed`]; infrastructure faults +/// (bind/exchange transport, 429, 5xx, or a malformed success body) are +/// [`NetworkUnavailable`]. +/// +/// [`BrowserOpenFailed`]: AuthError::BrowserOpenFailed +/// [`TimedOut`]: AuthError::TimedOut +/// [`Denied`]: AuthError::Denied +/// [`ExchangeFailed`]: AuthError::ExchangeFailed +/// [`NetworkUnavailable`]: AuthError::NetworkUnavailable async fn browser_pkce_flow( http: &Client, cfg: &PkceOAuthConfig, endpoints: &OidcEndpoints, -) -> Result { + opener: &dyn BrowserOpener, +) -> Result { use axum::{extract::Query, response::Html, routing::get, Router}; use std::collections::HashMap; use std::net::SocketAddr; use tokio::sync::oneshot; - let (verifier, challenge) = pkce_pair()?; - let state = random_state()?; + let (verifier, challenge) = pkce_pair().map_err(|_| AuthError::NetworkUnavailable)?; + let state = random_state().map_err(|_| AuthError::NetworkUnavailable)?; let (tx, rx) = oneshot::channel::>(); let tx = Arc::new(Mutex::new(Some(tx))); @@ -749,10 +2078,10 @@ async fn browser_pkce_flow( let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) .await - .map_err(|e| AgentError::Llm(format!("oauth callback bind: {e}")))?; + .map_err(|_| AuthError::NetworkUnavailable)?; let port = listener .local_addr() - .map_err(|e| AgentError::Llm(format!("oauth callback addr: {e}")))? + .map_err(|_| AuthError::NetworkUnavailable)? .port(); let redirect_uri = format!("http://localhost:{port}"); @@ -774,14 +2103,25 @@ async fn browser_pkce_flow( urlencoding::encode(&challenge), ); - eprintln!("Opening browser for authentication. If it doesn't open, visit:\n {auth_url}"); - let _ = webbrowser::open(&auth_url); + // Launch the browser while the listener is live. A launch failure aborts + // before we wait on a redirect nobody can send. + opener.open(&auth_url).map_err(|e| { + tracing::warn!(error = %e, "oauth browser launch failed"); + AuthError::BrowserOpenFailed + })?; - let code = tokio::time::timeout(BROWSER_AUTH_TIMEOUT, rx) - .await - .map_err(|_| AgentError::Llm("oauth: browser auth timed out".into()))? - .map_err(|_| AgentError::Llm("oauth: callback sender dropped".into()))? - .map_err(|e| AgentError::Llm(format!("oauth callback: {e}")))?; + let code = match tokio::time::timeout(BROWSER_AUTH_TIMEOUT, rx).await { + // Timed out waiting for the redirect. + Err(_) => return Err(AuthError::TimedOut), + // Callback task dropped the sender without sending — treat as timeout. + Ok(Err(_)) => return Err(AuthError::TimedOut), + // Provider/user reported an error (denial, state mismatch, missing code). + Ok(Ok(Err(detail))) => { + tracing::warn!(detail = %detail, "oauth callback reported failure"); + return Err(AuthError::Denied); + } + Ok(Ok(Ok(code))) => code, + }; // Exchange code for token. let params = [ @@ -796,21 +2136,47 @@ async fn browser_pkce_flow( .form(¶ms) .send() .await - .map_err(|e| AgentError::Llm(format!("oauth exchange: {e}")))?; - if !resp.status().is_success() { + // Transport error or the per-request timeout elapsed: no verdict from + // the provider, so this is infrastructural, not a rejected grant. + .map_err(|_| AuthError::NetworkUnavailable)?; + let status = resp.status(); + if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return Err(AgentError::Llm(format!("oauth exchange failed: {body}"))); + // Only a 4xx `invalid_grant` (RFC 6749 §6.4.1) establishes the + // authorization code itself was rejected — the terminal, cooldown-worthy + // `ExchangeFailed`. A 429, any 5xx, and any other/unparseable 4xx are a + // transient provider fault or misconfiguration a cooldown must not + // suppress, so they surface as `NetworkUnavailable` — mirroring the + // refresh classifier, which likewise keys on the body `error`, not the + // bare status class. + if status.is_client_error() + && serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_owned)) + .as_deref() + == Some("invalid_grant") + { + tracing::warn!(status = %status, body = %body, "oauth code exchange rejected"); + return Err(AuthError::ExchangeFailed); + } + tracing::warn!(status = %status, body = %body, "oauth code exchange not a grant rejection"); + return Err(AuthError::NetworkUnavailable); } + // A 2xx whose body is missing/malformed or lacks an access token is a + // provider fault, not a rejected grant: it never establishes that the code + // was refused, so it stays in the transient bucket rather than poisoning a + // 5-minute cooldown. let v: Value = resp .json() .await - .map_err(|e| AgentError::Llm(format!("oauth exchange json: {e}")))?; - token_from_response(&v, None) + .map_err(|_| AuthError::NetworkUnavailable)?; + token_from_response(&v, None).map_err(|_| AuthError::NetworkUnavailable) } #[cfg(test)] mod tests { use super::*; + use std::time::Instant; #[test] fn pkce_pair_produces_valid_challenge() { @@ -862,7 +2228,41 @@ mod tests { } #[test] - fn cache_path_uses_platform_home_directory() { + fn production_and_demo_oauth_roots_are_concrete_and_distinct() { + let home = PathBuf::from("/Users/demo"); + let production = oauth_cache_root_for(None, Some(home.clone())).unwrap(); + let first_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-1234567812345678"); + let second_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-8765432187654321"); + let first_demo = oauth_cache_root_for(Some(first_demo_config), Some(home.clone())).unwrap(); + let second_demo = oauth_cache_root_for(Some(second_demo_config), Some(home)).unwrap(); + + assert_eq!( + production, + PathBuf::from("/Users/demo/.config/buzz-agent/oauth") + ); + assert_eq!( + first_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) + ); + assert_eq!( + second_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-8765432187654321/buzz-agent/oauth" + ) + ); + assert_ne!(production, first_demo); + assert_ne!(production, second_demo); + assert_ne!(first_demo, second_demo); + } + + #[test] + fn cache_path_preserves_production_home_config_directory() { let cfg = PkceOAuthConfig { discovery_url: "https://example.com/.well-known".into(), client_id: "abc".into(), @@ -902,6 +2302,7 @@ mod tests { assert!(token_from_response(&v, None).is_err()); } + #[cfg(unix)] // Disk adoption relies on `write_private_cache`; non-Unix disables disk persistence. #[tokio::test] async fn test_bearer_reuses_disk_token_after_expiry() { let dir = tempfile::tempdir().unwrap(); @@ -943,11 +2344,31 @@ mod tests { assert_eq!(result, "fresh-from-disk"); } + /// A joiner that wakes to the leader's shared *failure* must still recover + /// a sibling's valid replacement from disk. The matching-failure path + /// neutralizes the joiner's own rejected state (under `lock().await`) and + /// then reads the disk lock-free — so a shared failure never forces an + /// N-way browser storm when a sibling already wrote a valid cache entry. + /// + /// The disk replacement is written AFTER B has deterministically joined the + /// slot (held state guard forces the joiner path; poll 1 confirms B is + /// blocked on `state.lock().await`). This ensures the test actually + /// exercises the joiner recovery branch rather than the initial fast-path + /// `cached_hit`. Removing the joiner disk-recovery branch must make the + /// test return Err(RefreshRejected) rather than Ok("sibling-replacement"). + /// + /// Disk-dependent: the replacement lives on disk, so `write_private_cache` + /// must be available (i.e. Unix only). + #[cfg(unix)] #[tokio::test] - async fn test_bearer_falls_through_to_browser_when_disk_also_expired() { + async fn test_joiner_shared_failure_recovers_disk_replacement() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + let dir = tempfile::tempdir().unwrap(); let cfg = PkceOAuthConfig { - discovery_url: "https://example.com/.well-known".into(), + discovery_url: "https://invalid.example.test/.well-known".into(), client_id: "test-client".into(), scopes: vec!["offline_access".into()], cache_namespace: "test".into(), @@ -955,7 +2376,201 @@ mod tests { }; let source = PkceOAuthTokenSource::new(cfg).unwrap(); - // Expire the in-memory state. + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + let replacement = CachedToken { + access_token: "sibling-replacement".into(), + refresh_token: Some("rt".into()), + expires_at: Some(future_exp), + }; + + // Pre-install a slot for this key and publish the leader's terminal + // failure — digest matches "rejected-bytes" so the joiner enters the + // in-memory neutralization branch. + let key: InflightKey = (source.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + slot.publish( + digest_of(Some("rejected-bytes")), + Err(AuthError::RefreshRejected), + ); + + // Hold the state mutex so the fast-path `try_lock` fails and B is + // forced down the joiner path. The slot is already published, so + // `slot.wait()` returns immediately; B then calls `state.lock().await` + // and suspends while we hold the guard. + let state_guard = source.state.lock().await; + + let mut b_fut = pin!(source.acquire(AuthIntent::Headless, Some("rejected-bytes"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + + // Poll 1: B falls through fast-path (try_lock fails), joins the + // pre-published slot, enters the Err match arm, and blocks on + // `state.lock().await` — structural proof B is on the joiner path. + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 1 must be Pending: B is blocked at state.lock().await after waking to Err" + ); + + // Now install the disk replacement. B is definitely past the initial + // fast-path and will only see this token via `usable_from_disk` after + // reconciliation — the recovery branch we are testing. + fs::write( + &source.cache_path, + serde_json::to_vec(&replacement).unwrap(), + ) + .unwrap(); + + // Release the mutex. B acquires the lock, calls expire_rejected_memory + // (empty state — no-op), then reads the disk replacement via + // `usable_from_disk` and returns Ok("sibling-replacement"). + // + // Mutation check: removing the `usable_from_disk` recovery branch + // makes B return Err(RefreshRejected) instead — the assertion fails. + drop(state_guard); + + let result = b_fut.await; + inflight_registry().remove(&key); + + assert_eq!( + result, + Ok("sibling-replacement".to_string()), + "the joiner must read the disk replacement and not inherit the shared failure — \ + mutation check: removing the usable_from_disk branch returns Err(RefreshRejected)" + ); + } + + /// **Joiner failure cleanup must not modify the shared disk cache.** + /// + /// The matching-failure joiner calls `expire_rejected_memory` (in-process + /// state only). It must not write, truncate, rename, or remove the disk + /// cache. An independent process C may have persisted a valid replacement + /// under the cross-process file lock between A's failure and B's + /// reconciliation; an unfenced disk write from B would overwrite it. + /// + /// This test seeds X on disk, runs B as a joiner that wakes to a matching + /// failure, and asserts the disk file is byte-for-byte unchanged afterward. + /// + /// Mutation check: reverting the joiner arm to call `expire_rejected` + /// instead of `expire_rejected_memory` makes B read the disk file, see + /// `access_token == "rejected-X"`, set `expires_at = 0`, and overwrite the + /// file via `persist` or in-place truncate. The disk bytes change, and the + /// "disk unchanged" assertion fails — proving the unfenced write is exactly + /// the race that would overwrite any concurrent C write that landed between + /// A's failure and B's reconciliation. + #[cfg(unix)] + #[tokio::test] + async fn test_joiner_failure_does_not_write_disk() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + let dir = tempfile::tempdir().unwrap(); + let cfg = PkceOAuthConfig { + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }; + let b = PkceOAuthTokenSource::new(cfg).unwrap(); + + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + + // Seed X on disk. The constructor may not create the parent directory + // without a pre-existing file, so ensure it exists first. + let token_x = CachedToken { + access_token: "rejected-X".into(), + refresh_token: Some("live-refresh".into()), + expires_at: Some(future_exp), + }; + if let Some(parent) = b.cache_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let disk_before = serde_json::to_vec(&token_x).unwrap(); + fs::write(&b.cache_path, &disk_before).unwrap(); + + // Pre-install a matching-failure slot (digest matches "rejected-X"). + let key: InflightKey = (b.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + slot.publish( + digest_of(Some("rejected-X")), + Err(AuthError::RefreshRejected), + ); + + // Hold B's state mutex: fast-path try_lock fails → joiner path; + // state.lock().await during reconciliation blocks until we drop. + let state_guard = b.state.lock().await; + + let mut b_fut = pin!(b.acquire(AuthIntent::Headless, Some("rejected-X"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + + // Poll 1: B falls through fast-path, joins the pre-published slot, + // wakes to Err, and parks at state.lock().await. + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 1 must be Pending: B is parked at state.lock().await after waking to Err" + ); + + // Release the state guard. B acquires the lock, calls + // expire_rejected_memory (in-memory neutralization only — no disk I/O), + // then checks usable_from_disk. The disk token's access_token is + // "rejected-X" which equals `rejected`, so usable_from_disk filters it + // and returns None. B returns Err(RefreshRejected). + drop(state_guard); + + let result = b_fut.await; + inflight_registry().remove(&key); + + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "B must propagate the shared failure" + ); + + // The disk file must be byte-for-byte identical to what was seeded. + // expire_rejected_memory must not have touched it. + // + // Mutation check: expire_rejected reads the disk file, finds + // access_token == "rejected-X", sets expires_at = 0, and rewrites + // the file. The bytes change and this assertion fails — proving the + // unfenced write is the exact race that overwrites a concurrent C write + // landing between A's failure and B's reconciliation. + let disk_after = fs::read(&b.cache_path).unwrap(); + assert_eq!( + disk_after, disk_before, + "joiner failure cleanup must not modify the disk cache — \ + mutation check: expire_rejected rewrites the file (expires_at=0), \ + overwriting any concurrent write from process C" + ); + } + + #[tokio::test] + async fn test_bearer_headless_no_credential_is_terminal_without_browser() { + let dir = tempfile::tempdir().unwrap(); + let cfg = PkceOAuthConfig { + // Unreachable discovery URL: if bearer() ever attempts discovery or + // a browser flow, this test would hang or error differently. The + // headless path must not touch either. + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }; + let source = PkceOAuthTokenSource::new(cfg).unwrap(); + + // Expire the in-memory state with no refresh token. { let mut state = source.state.lock().await; *state = Some(CachedToken { @@ -965,7 +2580,7 @@ mod tests { }); } - // Write an expired token to disk too. + // Write an expired, refresh-less token to disk too. let expired_token = CachedToken { access_token: "also-stale".into(), refresh_token: None, @@ -974,27 +2589,25 @@ mod tests { let body = serde_json::to_vec_pretty(&expired_token).unwrap(); fs::write(&source.cache_path, &body).unwrap(); - // bearer() should fall through past the disk check. - // It will fail at the endpoints() discovery call since there's no server, - // which proves it didn't short-circuit on the expired disk token. - let result = source.bearer().await; - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("oauth discovery"), - "expected discovery error, got: {err_msg}" - ); + // bearer() is a Headless acquisition: past the cache checks with no + // refresh token, it returns terminally instead of opening a browser. + // With no refresh token it never even discovers endpoints, so the + // unreachable URL is never contacted — the error is a graceful + // LlmAuth, not a hard Llm/discovery error. + match source.bearer().await.unwrap_err() { + AgentError::LlmAuth(_) => {} // correct: terminal, no browser + other => panic!("expected terminal LlmAuth, got: {other:?}"), + } } - /// `try_bearer_no_browser` with an empty cache and no refresh token must + /// `bearer_no_browser` with an empty cache and no refresh token must /// return `LlmAuth` immediately — it must NOT attempt OIDC discovery even - /// when the `discovery_url` is unreachable/invalid. This guards the - /// regression where `endpoints()` was called unconditionally before the - /// refresh-token check, causing an `Llm` error (hard failure) instead of - /// the intended graceful `LlmAuth` fallback. + /// when the `discovery_url` is unreachable/invalid, and must never browse. + /// This guards the regression where `endpoints()` was called + /// unconditionally before the refresh-token check, causing an `Llm` error + /// (hard failure) instead of the intended graceful `LlmAuth` fallback. #[tokio::test] - async fn test_try_bearer_no_browser_empty_cache_no_refresh_returns_llm_auth_without_discovery() - { + async fn test_bearer_no_browser_empty_cache_no_refresh_returns_llm_auth_without_discovery() { let dir = tempfile::tempdir().unwrap(); // Intentionally invalid/unreachable discovery URL — if endpoints() is // called, the test will get an `Llm` error and the assertion below fails. @@ -1016,7 +2629,7 @@ mod tests { // No disk cache file either — dir is empty. - let result = source.try_bearer_no_browser().await; + let result = source.bearer_no_browser().await; assert!(result.is_err(), "expected Err, got Ok"); match result.unwrap_err() { AgentError::LlmAuth(_) => {} // correct: graceful fallback @@ -1342,4 +2955,311 @@ mod tests { "read_cache followed a symlinked cache path" ); } + + // ---- cross-process advisory lock primitive -------------------------- + // + // The full 165s waiter bound (`LOCK_WAIT_TIMEOUT`) is not exercisable in a + // unit test, so these drive `acquire_auth_lock` with explicit deadlines to + // pin the three properties the coordinator relies on: a contended waiter + // times out (never blocks forever), a timeout leaves the *holder* + // untouched (never cancels the in-flight attempt), and releasing the + // holder — the RAII stand-in for a crashed process — lets a successor + // proceed with no wedge and no lock-breaking. + + #[tokio::test] + async fn test_lock_wait_times_out_and_leaves_holder_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cache.json.lock"); + + // Holder takes the lock with a generous deadline. + let holder = acquire_auth_lock(&path, Instant::now() + Duration::from_secs(30)) + .await + .expect("holder should acquire the free lock"); + + // A waiter with an already-lapsed deadline must give up with + // LockTimeout rather than block — this is the deadline-aware polling + // that replaces a blocking `lock()`. + let waiter = acquire_auth_lock(&path, Instant::now()).await; + assert!( + matches!(waiter, Err(AuthError::LockTimeout)), + "contended waiter past its deadline must return LockTimeout, got {waiter:?}" + ); + + // The timeout did not cancel or steal the holder: a second immediate + // waiter still cannot acquire, proving the holder is intact. + let still_held = acquire_auth_lock(&path, Instant::now()).await; + assert!( + matches!(still_held, Err(AuthError::LockTimeout)), + "holder must remain intact after a waiter times out, got {still_held:?}" + ); + + drop(holder); + } + + #[tokio::test] + async fn test_lock_timeout_leaves_cooldown_sidecar_byte_for_byte_untouched() { + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join("cache.json.lock"); + let cooldown_path = dir.path().join("cache.json.cooldown"); + + // A pre-existing cooldown sidecar written by an earlier interactive + // failure. A waiter that can't take the lock must return before any + // code that reads/clears/writes the cooldown, so these exact bytes + // survive untouched — otherwise a lock-contended caller could clear a + // live suppression and let the next Auto caller re-pop a browser. + let original = br#"{"code":"denied","until":9999999999}"#; + fs::write(&cooldown_path, original).unwrap(); + + // Holder owns the lock (RAII stand-in for another live process). + let holder = acquire_auth_lock(&lock_path, Instant::now() + Duration::from_secs(30)) + .await + .expect("holder should acquire the free lock"); + + // A waiter past its deadline gives up with LockTimeout — the `?` in + // `acquire_leader` propagates this before `acquire_locked` (which owns + // every sidecar mutation) is ever entered. + let waiter = acquire_auth_lock(&lock_path, Instant::now()).await; + assert!( + matches!(waiter, Err(AuthError::LockTimeout)), + "contended waiter past its deadline must return LockTimeout, got {waiter:?}" + ); + + let after = fs::read(&cooldown_path).unwrap(); + assert_eq!( + after.as_slice(), + original.as_slice(), + "a lock timeout must leave the cooldown sidecar byte-for-byte untouched" + ); + + drop(holder); + } + + /// **Awaited reconciliation is falsifiable — `lock().await` cannot regress to `try_lock`.** + /// + /// Deterministic direct-poll proof: the test task holds B's state mutex and + /// manually polls a pinned real `acquire()` future at each state transition, + /// without spawning a task or relying on scheduler ordering. + /// + /// Proof sequence: + /// 1. Seed B's state with stale X; register an unpublished slot. + /// 2. Hold B's state mutex — blocks the fast-path `try_lock` so B falls + /// through to the registry, and will block `lock().await` when B tries + /// to reconcile after waking. + /// 3. Poll B's `acquire()` once: no prior async suspension on the joiner + /// path, so B reaches `slot.wait()`'s inner `rx.changed().await` and + /// parks — the poll returns `Pending`. This is a structural proof, not a + /// scheduler assumption. + /// 4. Publish Y and poll the same future again while the state mutex is + /// still held. `slot.wait()` wakes and returns; B calls + /// `state.lock().await`, which must park because we hold the mutex → + /// this poll returns `Pending`. + /// Mutation check: with `try_lock()` the adopt block is skipped and B + /// returns immediately → this poll returns `Ready(Ok("token-Y"))`, + /// failing the `Pending` assertion. + /// 5. Release the state guard; poll to completion (or `await` the future) + /// and assert the result is `Ok("token-Y")`. + /// 6. Assert a subsequent plain `acquire(None)` returns Y from the + /// in-memory cache — the P1 contract. + /// Mutation check: `try_lock` leaves state == stale X, so this acquire + /// returns X — the exact P1 stale-credential regression. + #[tokio::test] + async fn test_joiner_reconciliation_blocked_until_state_lock_released() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + let dir = tempfile::tempdir().unwrap(); + let b = PkceOAuthTokenSource::new(PkceOAuthConfig { + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }) + .unwrap(); + + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + let make_token = |access: &str| CachedToken { + access_token: access.into(), + refresh_token: Some("rt".into()), + expires_at: Some(future_exp), + }; + + let token_x = make_token("token-X"); // B's stale/rejected credential. + let token_y = make_token("token-Y"); // shared leader result — must replace X. + + // Seed B's state with stale X. + { + let mut state = b.state.lock().await; + *state = Some(token_x.clone()); + } + + // Register an unpublished slot so B will join it. + let key: InflightKey = (b.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + + // Hold B's state mutex. + // (a) The fast-path `try_lock` fails → B falls through to the joiner path. + // (b) `state.lock().await` during reconciliation will block until we drop. + let state_guard = b.state.lock().await; + + // Pin B's acquire() future in this stack frame for manual polling. + let mut b_fut = pin!(b.acquire(AuthIntent::Headless, Some("token-X"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + + // Poll 1: B has no async suspension before `slot.wait()`'s inner + // `rx.changed().await`. The slot is unpublished, so `changed()` parks. + // Result must be Pending — structural proof that B reached slot.wait(). + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 1 must be Pending: B is parked at slot.wait() awaiting publication" + ); + + // Publish Y. `rx.changed()` wakes; on the next poll B exits slot.wait(), + // enters reconciliation, and calls `state.lock().await`. + slot.publish(None, Ok(token_y.clone())); + inflight_registry().remove(&key); + + // Poll 2: `slot.wait()` returns Y; B calls `state.lock().await`. + // With `lock().await`: the mutex is held → parks → Pending. + // Mutation (`try_lock`): try_lock fails → adopt skipped → B returns + // Ok("token-Y") immediately → Ready, not Pending. + // + // This poll is the exact mutation discriminator: Ready here is the + // bug (B completed without awaited reconciliation). + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 2 must be Pending: B must not return while state mutex is held — \ + mutation check: `try_lock()` returns Ready here, proving early completion \ + without reconciliation (the P1 regression)" + ); + + // Release the mutex. B acquires the lock, evaluates the adoption + // predicate (state == stale X, matches the rejected token), writes Y, + // and returns Ok("token-Y"). + drop(state_guard); + + // Await completion (B now owns the mutex). + let result = b_fut.await; + assert_eq!( + result, + Ok("token-Y".to_string()), + "B must return the shared token Y after reconciliation completes" + ); + + // Subsequent plain acquire must return Y from the in-memory cache — + // the P1 contract. With the `try_lock` mutation, state still holds X + // and this acquire returns X (stale-credential regression). + let rb_next = b + .acquire(AuthIntent::Headless, None) + .await + .expect("subsequent acquire must return Y from in-memory state"); + assert_eq!( + rb_next, "token-Y", + "subsequent in-memory read must return Y, not stale X — \ + mutation check: `try_lock()` leaves state == X, returning X" + ); + } + + /// **Preserve-distinct-newer — reconciliation must not overwrite B's valid credential.** + /// + /// B already holds a valid, usable token Z (distinct from rejected X and from the + /// leader's shared result Y) in its `state` when the joiner reconciliation runs. + /// The adoption predicate must evaluate to false for Z and leave it in place. + /// + /// Deterministic setup via direct polling: register an unpublished slot; poll + /// B's `acquire()` once to park it at `slot.wait()`; write Z into B's state; + /// publish Y and await completion. No scheduler inference or `yield_now()`. + /// + /// Mutation check (unconditional adoption): if the reconciliation block writes + /// `*state = Some(token.clone())` unconditionally, Z is overwritten with Y. + /// The subsequent state assertion `state == Z` FAILS — proving the predicate + /// is load-bearing. + #[tokio::test] + async fn test_joiner_preserve_distinct_newer_credential() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + let dir = tempfile::tempdir().unwrap(); + let b = PkceOAuthTokenSource::new(PkceOAuthConfig { + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }) + .unwrap(); + + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + let make_token = |access: &str| CachedToken { + access_token: access.into(), + refresh_token: Some("rt".into()), + expires_at: Some(future_exp), + }; + + let token_z = make_token("token-Z"); // B's distinct, independently acquired credential. + let token_y = make_token("token-Y"); // leader's shared result — must NOT overwrite Z. + + // Register a not-yet-published slot so B will join it and wait. + // B starts with empty state so its fast-path cache miss is guaranteed. + let key: InflightKey = (b.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + + // Pin B's future and poll once to park it at slot.wait(). + // No async suspension precedes slot.wait() on the joiner path, so the + // first poll is the structural proof that B is parked there. + let mut b_fut = pin!(b.acquire(AuthIntent::Headless, Some("token-X"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "B must park at slot.wait() on the first poll" + ); + + // B is now suspended in slot.wait(). Write Z into B's state — this is an + // intervening write that B will observe when it evaluates the + // reconciliation predicate after waking. + { + let mut state = b.state.lock().await; + *state = Some(token_z.clone()); + } + + // Publish Y to wake B. B will call lock().await, see Z (not expired, not + // matching "token-X"), evaluate the predicate as false, and preserve Z. + slot.publish(None, Ok(token_y.clone())); + inflight_registry().remove(&key); + + let result = b_fut.await; + + assert_eq!( + result, + Ok("token-Y".to_string()), + "B must still receive the shared bearer Y" + ); + + // B.state must still hold Z — the adoption predicate correctly skipped + // the write because Z is usable and distinct from the rejected token. + { + let state = b.state.lock().await; + assert_eq!( + state.as_ref().map(|t| t.access_token.as_str()), + Some("token-Z"), + "B.state must not be overwritten when it holds a distinct usable credential — \ + mutation check: fails if reconciliation is unconditional \ + (overwrites Z with Y regardless of predicate)" + ); + } + } } diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 82f3b086cd6..f2cda834fd1 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -12,7 +12,7 @@ //! This helper never opens a browser. Callers choose whether to reject, degrade, //! or start a separate interactive authentication flow. -use std::{collections::HashSet, sync::Arc, time::Duration}; +use std::{collections::HashSet, path::Path, sync::Arc, time::Duration}; use reqwest::Client; use serde_json::Value; @@ -133,7 +133,26 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// # Panics /// Never panics. pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> { - discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await + discover_databricks_models_with_cache_dir(cfg, None).await +} + +/// Discover Databricks models while storing PKCE credentials under an explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn discover_databricks_models_with_cache_dir( + cfg: &Config, + cache_dir: Option<&Path>, +) -> Result, AgentError> { + let token_source = if matches!(cfg.provider, Provider::Databricks | Provider::DatabricksV2) + && cfg.api_key.is_empty() + { + crate::auth::PkceOAuthTokenSource::new(crate::llm::databricks_pkce_config( + &cfg.base_url, + cache_dir.map(Path::to_path_buf), + ))? + } else { + build_token_source(cfg)? + }; + discover_databricks_models_with_token_source(cfg, token_source).await } async fn discover_databricks_models_with_token_source( diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 202d73e5548..5b2f1d659d2 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -709,7 +709,7 @@ impl Config { max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 65_536)?, max_token_recoveries: parse_env("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", 3u32)?, llm_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_LLM_TIMEOUT_SECS", 240)?), - tool_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", 660)?), + tool_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", 1_260)?), mcp_init_timeout: Duration::from_secs(parse_env( "BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", 30, @@ -2462,4 +2462,24 @@ mod tests { assert_eq!(pricing_authority("https://api.databricks.com/v1"), None); assert_eq!(pricing_authority("https://custom.llm.corp/v1"), None); } + + #[test] + fn default_tool_timeout_is_1260_seconds() { + // Lock the production default so accidental regressions are caught. + // This value must remain >= buzz-dev-mcp's MAX_TIMEOUT_MS (1_200s) to + // give every shell(timeout_ms=1_200_000) call time to complete before + // buzz-agent kills the MCP server. See PR #7185 for the full budget chain. + // + // 1_260s is the literal default passed to parse_env in Config::from_env(). + // Update here if and only if you update that literal; the test name makes + // "grep for old value" reliable. + const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 1_260; + const { + // Shell cap (1_200_000 ms = 1_200s) must fit inside the agent timeout. + assert!( + 1_200u64 <= DEFAULT_TOOL_TIMEOUT_SECS, + "agent tool timeout must be >= dev-mcp shell cap (1200s)" + ); + } + } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index b094a0f9fd7..3de47c82a4a 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -13,7 +13,9 @@ mod permission; pub mod types; mod wire; -pub use catalog::{discover_databricks_models, ModelEntry}; +pub use catalog::{ + discover_databricks_models, discover_databricks_models_with_cache_dir, ModelEntry, +}; pub use config::Provider; pub use types::AgentError; @@ -161,10 +163,22 @@ pub fn run() -> Result<(), Box> { Ok(()) } +/// Authenticate to Databricks and store credentials under an optional explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn authenticate_databricks_with_cache_dir( + host: &str, + cache_dir: Option<&std::path::Path>, +) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config( + host, + cache_dir.map(std::path::Path::to_path_buf), + ))? + .interactive_login() + .await +} + pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { - auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))? - .interactive_login() - .await + authenticate_databricks_with_cache_dir(host, None).await } /// `buzz-agent auth ` — run the interactive auth flow for a diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 1d46c16e163..1bac5147743 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -2033,7 +2034,10 @@ where ))) } -pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { +pub(crate) fn databricks_pkce_config( + host: &str, + cache_dir_override: Option, +) -> PkceOAuthConfig { PkceOAuthConfig { discovery_url: format!( "{}/oidc/.well-known/oauth-authorization-server", @@ -2045,7 +2049,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { .map(|scope| (*scope).into()) .collect(), cache_namespace: "databricks".into(), - cache_dir_override: None, + cache_dir_override, } } @@ -2070,6 +2074,7 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A } Ok(PkceOAuthTokenSource::new(databricks_pkce_config( &cfg.base_url, + None, ))?) } } diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 42c9cc48780..a848557ae2f 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -45,6 +45,9 @@ const PASSTHROUGH_ENV: &[&str] = &[ "LC_ALL", "TMPDIR", "XDG_CONFIG_HOME", + // Explicit Buzz-owned OAuth root for named demo builds. The agent may spawn + // auth-capable child tools after clearing its ambient environment. + "BUZZ_AGENT_CONFIG_DIR", // SSH — required for git clone/push over SSH (git@github.com:...) "SSH_AUTH_SOCK", "SSH_AGENT_PID", diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs index 940448dd2e4..53f2d290ff6 100644 --- a/crates/buzz-agent/src/model_capabilities.rs +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -622,6 +622,8 @@ mod tests { Q::Vector { id: "dbv2-claude-opus-4-7-probe", provider: "databricks_v2", raw_model_id: "claude-opus-4-7", note: None }, Q::Vector { id: "dbv2-databricks-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-7", note: Some("Probes stripping of the databricks- catalog prefix.") }, Q::Vector { id: "dbv2-goose-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes stripping of the goose- catalog prefix.") }, + Q::Vector { id: "dbv2-goose-claude-4-6-sonnet-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-4-6-sonnet", note: Some("Probes the discovered Goose Sonnet 4.6 endpoint spelling and label.") }, + Q::Vector { id: "dbv2-goose-claude-4-7-opus-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-4-7-opus", note: Some("Probes the discovered Goose Opus 4.7 endpoint spelling and label.") }, Q::Vector { id: "dbv2-team-prefix-probe", provider: "databricks_v2", raw_model_id: "team-x-claude-opus-4-7", note: Some("Probes stripping of a team-x- catalog prefix.") }, Q::Vector { id: "dbv2-consolidated-llama-substring-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where a code word ('sol') appears only as a substring, not a boundary-aligned segment.") }, Q::Vector { id: "dbv2-terraform-coder-substring-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where a code word ('terra') is only a segment prefix, not a full segment.") }, @@ -632,12 +634,14 @@ mod tests { Q::Vector { id: "resolver-exact-raw-id-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes a raw id that has an exact record.") }, Q::Vector { id: "dbv2-claude-fable-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-fable-5", note: Some("Probes the canonical Databricks Fable 5 endpoint record.") }, Q::Vector { id: "dbv2-goose-claude-fable-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes a prefixed alias of the Databricks Fable 5 endpoint.") }, + Q::Vector { id: "dbv2-claude-fable-5-1-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-fable-5-1", note: Some("Probes the canonical Databricks Fable 5.1 endpoint record.") }, Q::Vector { id: "dbv2-claude-opus-4-8-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-8", note: Some("Probes the canonical Databricks Opus 4.8 endpoint record.") }, Q::Vector { id: "dbv2-goose-claude-opus-4-8-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-opus-4-8", note: Some("Probes a prefixed alias of the Databricks Opus 4.8 endpoint.") }, Q::Vector { id: "dbv2-claude-opus-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-5", note: Some("Probes the canonical Databricks Opus 5 endpoint record.") }, Q::Vector { id: "dbv2-goose-claude-opus-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-opus-5", note: Some("Probes a prefixed alias of the Databricks Opus 5 endpoint.") }, Q::Vector { id: "dbv2-claude-sonnet-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-sonnet-5", note: Some("Probes the canonical Databricks Sonnet 5 endpoint record.") }, Q::Vector { id: "dbv2-goose-claude-sonnet-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-sonnet-5", note: Some("Probes a prefixed alias of the Databricks Sonnet 5 endpoint.") }, + Q::Vector { id: "dbv2-kimi-2-7-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-kimi-2-7", note: Some("Probes the canonical Databricks Kimi 2.7 endpoint record.") }, Q::Vector { id: "dbv2-kimi-k3-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-kimi-k3", note: Some("Probes the canonical Databricks Kimi K3 endpoint record.") }, Q::Vector { id: "dbv2-goose-kimi-k3-alias-probe", provider: "databricks_v2", raw_model_id: "goose-kimi-k3", note: Some("Probes a prefixed alias of the Databricks Kimi K3 endpoint.") }, Q::Vector { id: "resolver-prefixed-alias-probe", provider: "databricks_v2", raw_model_id: "team-x-databricks-gpt-5-4-mini", note: Some("Probes a prefixed alias of an exact-record id (raw exact key differs).") }, @@ -728,6 +732,7 @@ mod tests { Q::Vector { id: "dbv2-gemini-3-pro-image-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gemini-3-pro-image", note: Some("Probes the Gemini 3 Pro Image endpoint record and label.") }, Q::Vector { id: "dbv2-deepseek-v4-flash-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-deepseek-v4-flash-0731", note: Some("Probes the DeepSeek V4 Flash endpoint record and label.") }, Q::Vector { id: "dbv2-deepseek-v4-pro-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-deepseek-v4-pro-0813", note: Some("Probes the DeepSeek V4 Pro endpoint record and label.") }, + Q::Vector { id: "dbv2-glm-5-3-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-glm-5-3", note: Some("Probes the GLM-5.3 endpoint record and label.") }, Q::Vector { id: "dbv2-glm-5-3-flash-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-glm-5-3-flash", note: Some("Probes the GLM-5.3 Flash endpoint record and label.") }, Q::Vector { id: "dbv2-grok-4-6-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-grok-4-6", note: Some("Probes the Grok 4.6 endpoint record and label.") }, Q::Vector { id: "dbv2-llama-4-maverick-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-llama-4-maverick", note: Some("Probes the Llama 4 Maverick endpoint record and label.") }, @@ -739,7 +744,7 @@ mod tests { Q::Vector { id: "dbv2-inkling-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-inkling", note: Some("Probes the Inkling endpoint record and label.") }, Q::Vector { id: "dbv2-uc-fqn-gemini-3-5-flash-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.gemini-3-5-flash", note: Some("Probes strip parity on a system.ai. UC FQN carrying the gemini- token (resolve carries no label; the alias label path is unit-tested).") }, Q::Vector { id: "dbv2-uc-fqn-meta-llama-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.meta-llama-3-3-70b-instruct", note: Some("Probes strip parity on a UC FQN where the llama- token strips through meta-.") }, - Q::Vector { id: "dbv2-uc-goose-deepseek-strip-probe", provider: "databricks_v2", raw_model_id: "data_workflow_tools.goose.goose-deepseek-v4-pro-0813", note: Some("Probes strip parity on a goose- prefixed UC FQN carrying the deepseek- token.") }, + Q::Vector { id: "dbv2-uc-fqn-deepseek-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.deepseek-v4-pro-0813", note: Some("Probes strip parity on a UC FQN carrying the deepseek- token.") }, Q::Vector { id: "dbv2-uc-fqn-inkling-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.inkling", note: Some("Probes strip parity on a UC FQN carrying the bare inkling token.") }, Q::Section { group: "Label/capability token isolation probes (#6955 review pass 1)", note: Some("Pins that label_family_tokens (the UC-humanization superset) never leaks into capability resolve(): capability stripping still uses only claude-/gpt-/kimi-, so a label token appearing before a gpt- marker must NOT displace the gpt-5-pro exact profile.") }, Q::Vector { id: "isolation-openai-gemini-gpt-5-pro-probe", provider: "openai", raw_model_id: "tenant-gemini-gpt-5-pro", note: Some("The gemini- label token must not strip here; capability resolve keeps the gpt-5-pro high-only profile.") }, @@ -839,7 +844,7 @@ mod tests { } #[test] - fn corpus_has_exactly_135_executable_vectors() { + fn corpus_has_exactly_140_executable_vectors() { // Locks the vector count so a silent INPUTS edit can't quietly drop // coverage; must equal the gate in the TS harness // (modelCapabilitiesCorpus.test.mjs). @@ -848,7 +853,7 @@ mod tests { .filter(|q| matches!(q, Q::Vector { .. })) .count(); assert_eq!( - vectors, 135, + vectors, 140, "corpus executable-vector count changed; update this gate deliberately" ); } @@ -869,7 +874,7 @@ mod tests { #[test] fn databricks_v2_fqn_uses_neutral_concrete_unknown_capabilities() { - let fqn = resolve("databricks_v2", "data_workflow_tools.goose.goose-kimi-k3"); + let fqn = resolve("databricks_v2", "system.ai.kimi-k3"); let fallback = resolve("databricks_v2", "some-unknown-xyz"); assert_eq!(fqn.thinking_mode, fallback.thinking_mode); assert_eq!(fqn.supported_efforts, fallback.supported_efforts); @@ -1018,9 +1023,12 @@ mod tests { Some("Claude Fable 5") ); for (alias, label) in [ + ("goose-claude-4-6-sonnet", "Claude Sonnet 4.6"), + ("goose-claude-4-7-opus", "Claude Opus 4.7"), ("goose-claude-opus-4-8", "Claude Opus 4.8"), ("goose-claude-opus-5", "Claude Opus 5"), ("goose-claude-sonnet-5", "Claude Sonnet 5"), + ("goose-kimi-2-7", "Kimi 2.7"), ("goose-kimi-k3", "Kimi K3"), ] { assert_eq!( @@ -1049,15 +1057,10 @@ mod tests { ("system.ai.qwen35-122b-a10b", "Qwen3.5 122B A10B"), ("system.ai.gemma-3-12b", "Gemma 3 12B"), ("system.ai.inkling", "Inkling"), - ( - "data_workflow_tools.goose.goose-deepseek-v4-flash-0731", - "DeepSeek V4 Flash", - ), - ( - "data_workflow_tools.goose.goose-glm-5-3-flash", - "GLM-5.3 Flash", - ), - ("data_workflow_tools.goose.goose-grok-4-6", "Grok 4.6"), + ("system.ai.deepseek-v4-flash-0731", "DeepSeek V4 Flash"), + ("system.ai.glm-5-3", "GLM-5.3"), + ("system.ai.glm-5-3-flash", "GLM-5.3 Flash"), + ("system.ai.grok-4-6", "Grok 4.6"), ] { assert_eq!(databricks_registry_label(fqn), Some(label), "fqn={fqn}"); } diff --git a/crates/buzz-agent/tests/bin/auth_worker.rs b/crates/buzz-agent/tests/bin/auth_worker.rs new file mode 100644 index 00000000000..5a4b76d2866 --- /dev/null +++ b/crates/buzz-agent/tests/bin/auth_worker.rs @@ -0,0 +1,252 @@ +//! Test-only helper: a real second process that runs the PUBLIC auth +//! coordinator (`PkceOAuthTokenSource::acquire_with_intent`) against a shared +//! temp cache, so the auth tests can prove the *cross-process* single-flight +//! contract end-to-end rather than with two in-process handles. +//! +//! The in-process `INFLIGHT` registry coalesces same-key callers within one +//! process before they ever reach the file lock, so two `PkceOAuthTokenSource` +//! instances in one test do NOT exercise the cross-process protocol (the OS +//! advisory lock and the on-disk cache re-read). This binary is a genuine +//! second process: it contends on the same `flock`/`LockFileEx` and reads/writes +//! the same private cache file the parent coordinator does. +//! +//! The browser step is scripted (no real window): the opener drives the +//! loopback callback exactly as a real browser would, and its launch count is +//! reported back so a test can assert "exactly one browser across processes". +//! +//! Env contract (all required unless noted): +//! AUTH_WORKER_DISCOVERY_URL — OIDC discovery URL (the parent stub). +//! AUTH_WORKER_CACHE_DIR — shared cache dir (`cache_dir_override`). +//! AUTH_WORKER_NAMESPACE — cache namespace. +//! AUTH_WORKER_CLIENT_ID — OAuth client id. +//! AUTH_WORKER_SCOPES — comma-separated scopes. +//! AUTH_WORKER_INTENT — auto | userinitiated | headless. +//! AUTH_WORKER_SCRIPT — approve | deny | failopen. +//! AUTH_WORKER_RESULT — path to write the JSON outcome to. +//! AUTH_WORKER_REJECTED — (optional) rejected token bytes passed to +//! `acquire_with_intent`; absent means no rejection. +//! AUTH_WORKER_READY_MARKER — (optional) written once the source is built, +//! before acquisition, so the parent can release +//! several workers into a genuine lock race. +//! AUTH_WORKER_START_MARKER — (optional) acquisition blocks until this file +//! exists, so multiple workers begin together. +//! AUTH_WORKER_LAUNCHED_MARKER — (optional) written when the browser opener +//! fires (i.e. this process holds the lock and is +//! mid-flow), so the parent can queue behind it. +//! AUTH_WORKER_PROCEED_MARKER — (optional) the scripted callback is withheld +//! until this file exists, so the parent can +//! confirm another process is already waiting on +//! the lock before this one resolves. +//! AUTH_WORKER_SNAPSHOT_MARKER — (optional) a file path; when set, a tracing +//! layer intercepts the `acquire_leader_snapshot` +//! event emitted by `auth.rs` after the attempt- +//! generation snapshot is taken (and before the +//! cross-process lock is acquired) and writes this +//! file once. Lets the parent observe that this +//! process has committed its snapshot-gen and is +//! about to queue on the lock. +//! +//! Result JSON: `{ "result": "ok"|"", "bearer": , +//! "launches": }`. + +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use buzz_agent::auth::{AuthIntent, BrowserOpener, PkceOAuthConfig, PkceOAuthTokenSource}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +/// Tracing layer that writes a file once when it sees the +/// `buzz_agent::auth::acquire_leader_snapshot` event emitted by +/// `acquire_leader` immediately after the attempt-generation snapshot is fixed +/// and before the cross-process lock is acquired. Installed only when +/// `AUTH_WORKER_SNAPSHOT_MARKER` is set, so normal test runs incur no overhead. +struct SnapshotMarkerLayer { + path: PathBuf, + written: AtomicBool, +} + +impl tracing_subscriber::Layer for SnapshotMarkerLayer { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + if event.metadata().target() == "buzz_agent::auth::acquire_leader_snapshot" + && !self.written.swap(true, Ordering::SeqCst) + { + let _ = fs::write(&self.path, b"snapshotted"); + } + } +} + +/// What the scripted "user" does when the coordinator opens a browser. +#[derive(Clone, Copy)] +enum Script { + Approve, + Deny, + FailToOpen, +} + +/// A [`BrowserOpener`] that counts launches and drives the loopback callback on +/// a background thread — the same technique as the in-crate test opener, but +/// with two optional cross-process barriers so the parent can order events: +/// `launched_marker` announces that this process holds the lock and has opened +/// the browser, and `proceed_marker` withholds the callback until the parent +/// signals it has queued another process behind the lock. +struct WorkerOpener { + script: Script, + calls: Arc, + launched_marker: Option, + proceed_marker: Option, +} + +impl BrowserOpener for WorkerOpener { + fn open(&self, url: &str) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(marker) = &self.launched_marker { + fs::write(marker, b"launched").expect("write launched marker"); + } + let query = match self.script { + Script::FailToOpen => return Err("no browser available".into()), + Script::Approve => "code=scripted-code", + Script::Deny => "error=access_denied", + }; + let parsed = url::Url::parse(url).expect("authorize URL must parse"); + let redirect = parsed + .query_pairs() + .find(|(k, _)| k == "redirect_uri") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries redirect_uri"); + let state = parsed + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries state"); + let redirect = url::Url::parse(&redirect).expect("redirect_uri must parse"); + let port = redirect.port().expect("loopback redirect carries a port"); + let request = format!( + "GET /?{query}&state={state} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + let proceed = self.proceed_marker.clone(); + std::thread::spawn(move || { + // Hold the callback until the parent has confirmed another process + // is already queued behind the lock (bounded so a missing signal + // can't wedge the test past the browser timeout). + if let Some(marker) = proceed { + for _ in 0..6000 { + if marker.exists() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + } + if let Ok(mut sock) = TcpStream::connect(("127.0.0.1", port)) { + let _ = sock.write_all(request.as_bytes()); + let _ = sock.flush(); + let mut discard = Vec::new(); + let _ = sock.read_to_end(&mut discard); + } + }); + Ok(()) + } +} + +fn env(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| panic!("{key} set")) +} + +#[tokio::main] +async fn main() { + // If the parent test set AUTH_WORKER_SNAPSHOT_MARKER, install a tracing + // subscriber layer that fires when the coordinator emits its pre-lock + // snapshot event and writes the marker file. + if let Ok(marker_path) = std::env::var("AUTH_WORKER_SNAPSHOT_MARKER") { + tracing_subscriber::registry() + .with(SnapshotMarkerLayer { + path: PathBuf::from(marker_path), + written: AtomicBool::new(false), + }) + .init(); + } + + let intent = match env("AUTH_WORKER_INTENT").as_str() { + "auto" => AuthIntent::Auto, + "userinitiated" => AuthIntent::UserInitiated, + "headless" => AuthIntent::Headless, + other => panic!("unknown AUTH_WORKER_INTENT: {other}"), + }; + let script = match env("AUTH_WORKER_SCRIPT").as_str() { + "approve" => Script::Approve, + "deny" => Script::Deny, + "failopen" => Script::FailToOpen, + other => panic!("unknown AUTH_WORKER_SCRIPT: {other}"), + }; + let result_path = PathBuf::from(env("AUTH_WORKER_RESULT")); + let start_marker = std::env::var("AUTH_WORKER_START_MARKER") + .ok() + .map(PathBuf::from); + let ready_marker = std::env::var("AUTH_WORKER_READY_MARKER") + .ok() + .map(PathBuf::from); + + let calls = Arc::new(AtomicU64::new(0)); + let opener = WorkerOpener { + script, + calls: calls.clone(), + launched_marker: std::env::var("AUTH_WORKER_LAUNCHED_MARKER") + .ok() + .map(PathBuf::from), + proceed_marker: std::env::var("AUTH_WORKER_PROCEED_MARKER") + .ok() + .map(PathBuf::from), + }; + + let cfg = PkceOAuthConfig { + discovery_url: env("AUTH_WORKER_DISCOVERY_URL"), + client_id: env("AUTH_WORKER_CLIENT_ID"), + scopes: env("AUTH_WORKER_SCOPES") + .split(',') + .map(str::to_owned) + .collect(), + cache_namespace: env("AUTH_WORKER_NAMESPACE"), + cache_dir_override: Some(PathBuf::from(env("AUTH_WORKER_CACHE_DIR"))), + }; + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener)).expect("build token source"); + + // Announce readiness, then wait for the parent's release so several workers + // hit the lock together — a genuine race rather than staggered spawns. + if let Some(marker) = &ready_marker { + fs::write(marker, b"ready").expect("write ready marker"); + } + if let Some(marker) = start_marker { + for _ in 0..6000 { + if marker.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + let (result, bearer) = match src + .acquire_with_intent( + intent, + std::env::var("AUTH_WORKER_REJECTED").ok().as_deref(), + ) + .await + { + Ok(token) => ("ok".to_owned(), Some(token)), + Err(e) => (e.code().to_owned(), None), + }; + let body = serde_json::json!({ + "result": result, + "bearer": bearer, + "launches": calls.load(Ordering::SeqCst), + }); + fs::write(&result_path, serde_json::to_vec(&body).unwrap()).expect("write result file"); +} diff --git a/crates/buzz-agent/tests/bin/lock_holder.rs b/crates/buzz-agent/tests/bin/lock_holder.rs new file mode 100644 index 00000000000..275503a762c --- /dev/null +++ b/crates/buzz-agent/tests/bin/lock_holder.rs @@ -0,0 +1,50 @@ +//! Test-only helper: a real second process that takes the coordinator's +//! cross-process advisory lock and holds it until killed. +//! +//! The auth coordinator single-flights per cache key on an `fs2` advisory lock +//! (`flock` on Unix, `LockFileEx` on Windows). To prove the *cross-process* +//! contract — a genuine other process serializes the flow, and its death +//! releases the lock with no PID files or lock-breaking — a test needs an +//! actual separate process on the same lock file, not a second in-process +//! handle. This binary is that process. +//! +//! Driven by two env vars: +//! LOCK_HELPER_PATH — the lock file to acquire (the coordinator's +//! `.json.lock`). +//! LOCK_HELPER_READY — a marker file created *after* the lock is held, so +//! the parent test can synchronize on ownership before +//! racing the coordinator. +//! +//! After signaling readiness it blocks forever; the parent kills it to model a +//! crash mid-flow. + +use std::fs; + +use fs2::FileExt; + +fn main() { + let lock_path = std::env::var("LOCK_HELPER_PATH").expect("LOCK_HELPER_PATH set"); + let ready_path = std::env::var("LOCK_HELPER_READY").expect("LOCK_HELPER_READY set"); + + if let Some(parent) = std::path::Path::new(&lock_path).parent() { + fs::create_dir_all(parent).expect("create lock parent dir"); + } + // Open exactly as the coordinator does so we contend on the same inode. + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .expect("open lock file"); + file.lock_exclusive() + .expect("hold the exclusive advisory lock"); + + // Signal ownership only once the lock is truly held. + fs::write(&ready_path, b"held").expect("write ready marker"); + + // Hold the lock until the parent kills us (crash stand-in). The kernel + // releases the advisory lock on process death. + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} diff --git a/crates/buzz-agent/tests/common/mod.rs b/crates/buzz-agent/tests/common/mod.rs index 02bdc3bc0ef..d45fac4985d 100644 --- a/crates/buzz-agent/tests/common/mod.rs +++ b/crates/buzz-agent/tests/common/mod.rs @@ -19,7 +19,7 @@ use std::time::Duration; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; -use tokio::sync::Mutex; +use tokio::sync::{oneshot, Mutex, Notify}; pub struct CapturingLlm { pub url: String, @@ -107,11 +107,22 @@ pub struct Harness { stdin: tokio::process::ChildStdin, stdout: BufReader, stderr: Arc>, + stderr_changed: Arc, next_id: i64, } impl Harness { pub async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { + Self::spawn_with_stderr_gate(base_url, extra, None).await + } + + /// Delay stderr collection until released, to exercise stdout/stderr ordering + /// without changing the child or relying on scheduler timing. + pub async fn spawn_with_stderr_gate( + base_url: &str, + extra: &[(&str, &str)], + stderr_gate: Option>, + ) -> Self { let bin = env!("CARGO_BIN_EXE_buzz-agent"); let mut cmd = tokio::process::Command::new(bin); cmd.env("BUZZ_AGENT_PROVIDER", "openai") @@ -135,7 +146,14 @@ impl Harness { let stderr = child.stderr.take().unwrap(); let stderr_buf = Arc::new(StdMutex::new(String::new())); let stderr_out = Arc::clone(&stderr_buf); + let stderr_changed = Arc::new(Notify::new()); + let changed = Arc::clone(&stderr_changed); tokio::spawn(async move { + if let Some(gate) = stderr_gate { + // Dropping the sender (e.g. on assertion failure) also unblocks + // collection, rather than leaving a detached reader waiting. + let _ = gate.await; + } let mut reader = BufReader::new(stderr); let mut line = String::new(); loop { @@ -150,6 +168,7 @@ impl Harness { if let Ok(mut out) = stderr_out.lock() { out.push_str(&line); } + changed.notify_waiters(); } }); Self { @@ -157,6 +176,7 @@ impl Harness { stdin, stdout, stderr: stderr_buf, + stderr_changed, next_id: 1, } } @@ -228,9 +248,36 @@ impl Harness { let _ = self.child.start_kill(); } + /// Snapshot only: receiving a response on stdout does not drain stderr. pub fn stderr_text(&self) -> String { self.stderr.lock().map(|s| s.clone()).unwrap_or_default() } + + /// Wait for a diagnostic in the independently collected stderr stream. + /// Returns the matching snapshot so subsequent assertions see its prefix. + pub async fn wait_for_stderr(&self, needle: &str, timeout: Duration) -> String { + tokio::time::timeout(timeout, async { + loop { + let changed = self.stderr_changed.notified(); + tokio::pin!(changed); + // Register before inspecting the buffer: a line collected between + // the snapshot and await must not become a lost wakeup. + changed.as_mut().enable(); + let stderr = self.stderr_text(); + if stderr.contains(needle) { + return stderr; + } + changed.await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "timed out waiting for stderr diagnostic {needle:?}; stderr={}", + self.stderr_text() + ) + }) + } } pub fn openai_text(content: &str) -> Value { diff --git a/crates/buzz-agent/tests/databricks_auth_coordinator.rs b/crates/buzz-agent/tests/databricks_auth_coordinator.rs new file mode 100644 index 00000000000..0937cc87c1d --- /dev/null +++ b/crates/buzz-agent/tests/databricks_auth_coordinator.rs @@ -0,0 +1,3410 @@ +//! Concurrency-matrix tests for the Databricks auth coordinator. +//! +//! The coordinator single-flights OAuth acquisition per cache key. Within one +//! process, same-key callers coalesce on an in-memory `INFLIGHT` registry +//! *before* the file lock; across processes, they serialize on an OS advisory +//! lock and share success through the on-disk cache, with failures coalesced +//! through a durable cooldown sidecar. These tests drive the public API +//! (`acquire_with_intent`, `interactive_login`) with an injected +//! [`BrowserOpener`] that scripts the localhost callback instead of popping a +//! real window — the browser step becomes deterministic and countable. +//! +//! Two `PkceOAuthTokenSource` instances in ONE process do not model two +//! processes: the `INFLIGHT` registry intercepts them before the file lock, so +//! same-process tests exercise the in-memory single-flight, not the +//! cross-process protocol. The genuinely cross-process claims — lock +//! contention, crash release, cooldown sharing across a process boundary, and +//! one-grant/one-cache under a real race — are proved with the `lock-holder` +//! and `auth-worker` helper binaries, each a real second process on the same +//! lock file and cache. The lock-primitive and lock-timeout edges live in the +//! in-crate `auth::tests` module where the private helpers are reachable. + +use std::io::Write; +use std::net::{SocketAddr, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::extract::Form; +use axum::{routing::get, routing::post, Json, Router}; +use buzz_agent::auth::{ + AuthError, AuthIntent, BrowserOpener, PkceOAuthConfig, PkceOAuthTokenSource, +}; +use serde::Deserialize; +use serde_json::json; +use tempfile::TempDir; + +// ---- scripted browser opener -------------------------------------------- + +/// What the scripted "user" does when the coordinator opens a browser. +#[derive(Clone, Copy)] +enum Script { + /// Redirect with a valid `code`+`state` → the flow exchanges it for a + /// token and succeeds. + Approve, + /// Redirect with `error=access_denied` → the flow returns `Denied`. + Deny, + /// Every launch strategy fails → the flow returns `BrowserOpenFailed` + /// without waiting on a listener nobody will reach. + FailToOpen, +} + +/// A [`BrowserOpener`] that counts launches and drives the localhost callback +/// on a background thread, so the caller's callback wait observes the redirect +/// exactly as a real browser would deliver it. +#[derive(Clone)] +struct ScriptedOpener { + script: Script, + calls: Arc, +} + +impl ScriptedOpener { + fn new(script: Script) -> Self { + Self { + script, + calls: Arc::new(AtomicU64::new(0)), + } + } + + fn call_count(&self) -> u64 { + self.calls.load(Ordering::SeqCst) + } +} + +impl BrowserOpener for ScriptedOpener { + fn open(&self, url: &str) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + let query = match self.script { + Script::FailToOpen => return Err("no browser available".into()), + Script::Approve => "code=scripted-code", + Script::Deny => "error=access_denied", + }; + // Pull the loopback redirect target and the anti-CSRF state out of the + // authorize URL, then fire the callback from a separate thread so this + // synchronous `open()` returns and the flow proceeds to await it. + let parsed = url::Url::parse(url).expect("authorize URL must parse"); + let redirect = parsed + .query_pairs() + .find(|(k, _)| k == "redirect_uri") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries redirect_uri"); + let state = parsed + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries state"); + let redirect = url::Url::parse(&redirect).expect("redirect_uri must parse"); + // The coordinator's listener binds 127.0.0.1; connect there directly so + // the callback can't land on an IPv6 `localhost` (::1) with no listener. + let port = redirect.port().expect("loopback redirect carries a port"); + // `state` is base64url (no reserved characters), safe to inline. + let request = format!( + "GET /?{query}&state={state} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + std::thread::spawn(move || { + // A real browser holds the connection open until the callback page + // responds; do the same so hyper dispatches the request before the + // socket closes (a bare write+drop races the server and is lost). + if let Ok(mut sock) = TcpStream::connect(("127.0.0.1", port)) { + use std::io::Read; + let _ = sock.write_all(request.as_bytes()); + let _ = sock.flush(); + let mut discard = Vec::new(); + let _ = sock.read_to_end(&mut discard); + } + }); + Ok(()) + } +} + +// ---- stub OIDC provider -------------------------------------------------- + +#[derive(Deserialize)] +struct TokenForm { + grant_type: String, +} + +struct Stub { + base: String, + /// authorization-code exchanges served (browser flows completed). + code_grants: Arc, + /// refresh-token grants served. + refresh_grants: Arc, +} + +/// How the stub's token endpoint answers a `refresh_token` grant. Lets a test +/// distinguish the three ways a refresh can fail so it can assert the +/// coordinator classifies each correctly: a `401` is a real credential +/// rejection (dead refresh token), a `500` is a transient provider fault, and +/// a hang models a slow/unreachable provider that must trip the per-request +/// HTTP timeout. Authorization-code grants are never affected. +#[derive(Clone, Copy)] +enum RefreshMode { + /// `200` with a fresh access token. + Succeed, + /// `401 invalid_grant` — the grant itself is rejected. + Reject, + /// `500` — a provider-side fault, transient rather than a credential + /// decision. + /// + /// Used only by Unix-only tests (refresh-error classification). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + ServerError, + /// A 4xx with the given OAuth `error` code in the body. Lets a test assert + /// the coordinator treats `invalid_grant` (any 4xx) as a dead grant, but + /// every other error code — and any non-`invalid_grant` status like `429` + /// — as infrastructural rather than a credential rejection. + /// + /// Used only by Unix-only tests (refresh-error classification). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + ClientError(axum::http::StatusCode, &'static str), + /// Sleep `d` before answering, so the caller's per-request HTTP timeout + /// elapses first (a transport timeout, not a verdict from the provider). + /// + /// Used only by Unix-only tests (refresh-timeout classification). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + Hang(Duration), + /// `200` returning the same fixed access token on every grant, regardless + /// of how many are served. Models a provider that re-issues an identical + /// access token, so a bounded rerun can hand back the exact bytes the + /// caller already reported 401-rejected. + /// + /// Used only by Unix-only tests (rejected-token neutralization, sticky + /// reissuance). Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + SucceedSticky(&'static str), +} + +/// How the stub's token endpoint answers an `authorization_code` grant (the +/// browser code exchange). Lets a test drive the exchange classifier: a +/// `401 invalid_grant` is a genuine rejected code (`ExchangeFailed`), while a +/// `429`, a `500`, and a malformed `200` are transient/provider faults that +/// must classify as `NetworkUnavailable` rather than poisoning the cooldown. +#[derive(Clone, Copy)] +enum ExchangeMode { + /// `200` with a fresh access token — the browser flow completes. + Succeed, + /// A failing status carrying the given OAuth `error` body. Only a 4xx + /// `invalid_grant` is a true code rejection; every other status/error is + /// infrastructural. + Fail(axum::http::StatusCode, &'static str), + /// `200` whose body lacks an `access_token` — a malformed success the + /// provider should never send, so it is a fault, not a rejected code. + MalformedSuccess, + /// Sleep `d` before answering, so the caller's per-request HTTP timeout + /// elapses first (a transport timeout, not a verdict from the provider). + Hang(Duration), + /// `200` returning the same fixed access token on every authorization-code + /// exchange. Models a provider that re-issues an identical access token, so + /// a browser sign-in (reached after a dead refresh) can hand back the exact + /// bytes the caller reported 401-rejected. + /// + /// Used only by Unix-only tests (sticky browser exchange after dead refresh). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + SucceedSticky(&'static str), +} + +/// Boot a stub provider. `reject_refresh` makes the token endpoint 401 every +/// refresh-token grant (a dead refresh token); authorization-code grants +/// always succeed with a fresh token. +async fn spawn_stub(reject_refresh: bool) -> Stub { + spawn_stub_with(if reject_refresh { + RefreshMode::Reject + } else { + RefreshMode::Succeed + }) + .await +} + +/// Boot a stub provider whose refresh-token grant follows `mode`. Discovery and +/// authorization-code grants always succeed instantly regardless of `mode`. +async fn spawn_stub_with(mode: RefreshMode) -> Stub { + spawn_stub_with_modes(mode, ExchangeMode::Succeed).await +} + +/// Boot a stub whose authorization-code exchange follows `exchange`. Refresh +/// grants succeed; used by the exchange-classifier tests. +async fn spawn_stub_with_exchange(exchange: ExchangeMode) -> Stub { + spawn_stub_with_modes(RefreshMode::Succeed, exchange).await +} + +/// Boot a stub provider whose refresh-token grant follows `refresh` and whose +/// authorization-code grant follows `exchange`. Discovery always succeeds. +async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) -> Stub { + let code_grants = Arc::new(AtomicU64::new(0)); + let refresh_grants = Arc::new(AtomicU64::new(0)); + + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let disco_base = base.clone(); + + let discovery = move || { + let base = disco_base.clone(); + async move { + Json(json!({ + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + })) + } + }; + + let code_for_token = code_grants.clone(); + let refresh_for_token = refresh_grants.clone(); + let app = Router::new() + // Two discovery paths so distinct-host tests derive distinct cache + // keys (the key hashes the discovery URL) from one stub. + .route("/disco/a", get(discovery.clone())) + .route("/disco/b", get(discovery)) + .route( + "/token", + post(move |Form(form): Form| { + let code_grants = code_for_token.clone(); + let refresh_grants = refresh_for_token.clone(); + let refresh = refresh; + let exchange = exchange; + async move { + if form.grant_type == "refresh_token" { + let n = refresh_grants.fetch_add(1, Ordering::SeqCst) + 1; + // A hang delays the answer so the caller's per-request + // HTTP timeout can elapse first (transport timeout, not + // a credential decision). + #[cfg(unix)] + if let RefreshMode::Hang(d) = refresh { + tokio::time::sleep(d).await; + } + return match refresh { + RefreshMode::Reject => ( + axum::http::StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid_grant" })), + ), + #[cfg(unix)] + RefreshMode::ServerError => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "temporarily_unavailable" })), + ), + #[cfg(unix)] + RefreshMode::ClientError(status, error) => { + (status, Json(json!({ "error": error }))) + } + RefreshMode::Succeed => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("refreshed-token-{n}"), + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ), + #[cfg(unix)] + RefreshMode::Hang(_) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("refreshed-token-{n}"), + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ), + #[cfg(unix)] + RefreshMode::SucceedSticky(tok) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": tok, + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ), + }; + } + let n = code_grants.fetch_add(1, Ordering::SeqCst) + 1; + // A hang delays the answer so the caller's per-request HTTP + // timeout can elapse first (transport timeout, not a code + // decision), mirroring the refresh path above. + if let ExchangeMode::Hang(d) = exchange { + tokio::time::sleep(d).await; + } + match exchange { + ExchangeMode::Succeed => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("browser-token-{n}"), + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ), + ExchangeMode::Fail(status, error) => { + (status, Json(json!({ "error": error }))) + } + ExchangeMode::MalformedSuccess => ( + axum::http::StatusCode::OK, + Json(json!({ "token_type": "bearer" })), + ), + #[cfg(unix)] + ExchangeMode::SucceedSticky(tok) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": tok, + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ), + // Reached only after the sleep above; answer as a + // success the caller has already abandoned. + ExchangeMode::Hang(_) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("browser-token-{n}"), + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ), + } + } + }), + ); + + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + Stub { + base, + code_grants, + refresh_grants, + } +} + +/// Control handle for a stub whose refresh response is held until the parent +/// explicitly releases it. Used by the cross-process digest test to establish +/// deterministic ordering: the parent waits for `request_received` (proves A +/// holds the lock and is mid-refresh), then spawns B, waits for B's snapshot +/// marker, and finally calls `release()` before joining both workers. +#[cfg(unix)] +struct RefreshGate { + /// Notified by the stub once it has received the first refresh request. + request_received: Arc, + /// Parent signals this to let the stub return the response. + proceed: Arc, +} + +#[cfg(unix)] +impl RefreshGate { + /// Asynchronously wait until the stub has received A's refresh request. + async fn wait_for_request(&self) { + self.request_received.notified().await; + } + + /// Release the held refresh response so the stub replies to A. + fn release(&self) { + self.proceed.notify_one(); + } +} + +/// Shape of the refresh response returned by [`spawn_stub_with_held_refresh`]. +/// +/// - `Sticky(tok)` — every refresh returns `200 OK` with `access_token: tok`. +/// - `Reject` — every refresh returns `401 Unauthorized` with `invalid_grant`. +#[cfg(unix)] +enum HeldRefreshResponse { + Sticky(&'static str), + Reject, +} + +/// Spawn a stub that holds the FIRST refresh request until the parent calls +/// [`RefreshGate::release()`], then replies according to `response`. +/// Subsequent refresh requests skip the gate and reply immediately with the +/// same shape. Code-grant (`authorization_code`) requests are always answered +/// immediately with a fresh browser token. +/// +/// Returns the stub (for `refresh_grants` / `code_grants` assertions) and the +/// control gate. Used by the cross-process held-refresh tests. +#[cfg(unix)] +async fn spawn_stub_with_held_refresh(response: HeldRefreshResponse) -> (Stub, RefreshGate) { + let code_grants = Arc::new(AtomicU64::new(0)); + let refresh_grants = Arc::new(AtomicU64::new(0)); + let request_received = Arc::new(tokio::sync::Notify::new()); + let proceed = Arc::new(tokio::sync::Notify::new()); + + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let disco_base = base.clone(); + + let discovery = move || { + let base = disco_base.clone(); + async move { + Json(json!({ + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + })) + } + }; + + let code_for_token = code_grants.clone(); + let refresh_for_token = refresh_grants.clone(); + let received_for_handler = request_received.clone(); + let proceed_for_handler = proceed.clone(); + // Track whether the first refresh has been released yet. Once the first + // grant is released, subsequent grants return immediately. + let first_released = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let reject = matches!(response, HeldRefreshResponse::Reject); + let sticky_tok = match response { + HeldRefreshResponse::Sticky(tok) => tok, + HeldRefreshResponse::Reject => "", + }; + + let app = Router::new() + .route("/disco/a", get(discovery.clone())) + .route("/disco/b", get(discovery)) + .route( + "/token", + post(move |Form(form): Form| { + let code_grants = code_for_token.clone(); + let refresh_grants = refresh_for_token.clone(); + let received = received_for_handler.clone(); + let proceed = proceed_for_handler.clone(); + let first_released = first_released.clone(); + async move { + if form.grant_type == "refresh_token" { + refresh_grants.fetch_add(1, Ordering::SeqCst); + // Hold only the first refresh request; once released, + // all subsequent requests return immediately. + if !first_released.swap(true, Ordering::SeqCst) { + received.notify_one(); + proceed.notified().await; + } + return if reject { + ( + axum::http::StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid_grant" })), + ) + } else { + ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": sticky_tok, + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ) + }; + } + let n = code_grants.fetch_add(1, Ordering::SeqCst) + 1; + ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("browser-token-{n}"), + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ) + } + }), + ); + + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let stub = Stub { + base, + code_grants, + refresh_grants, + }; + let gate = RefreshGate { + request_received, + proceed, + }; + (stub, gate) +} + +fn config(stub: &Stub, disco_path: &str, cache_dir: &std::path::Path) -> PkceOAuthConfig { + PkceOAuthConfig { + discovery_url: format!("{}{disco_path}", stub.base), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "databricks".into(), + cache_dir_override: Some(cache_dir.to_path_buf()), + } +} + +fn future_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600 +} + +fn cache_file_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf { + use sha2::Digest; + let mut h = sha2::Sha256::new(); + h.update(cfg.discovery_url.as_bytes()); + h.update(b"|"); + h.update(cfg.client_id.as_bytes()); + h.update(b"|"); + h.update(cfg.scopes.join(",").as_bytes()); + let hash = hex::encode(h.finalize()); + cache_dir + .join(&cfg.cache_namespace) + .join(format!("{hash}.json")) +} + +/// The cross-process attempt sidecar path for a config, matching the +/// coordinator's `append_ext(cache_path, "attempt")`. Used by tests that +/// inspect the generation counter directly after a cross-process adoption to +/// verify the adopter did not re-write a new generation. +fn attempt_sidecar_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf { + let mut p = cache_file_path(cfg, cache_dir).into_os_string(); + p.push(".attempt"); + p.into() +} + +/// The cross-process advisory lock path for a config, matching the +/// coordinator's `append_ext(cache_path, "lock")`. Used to point the +/// out-of-process lock-holder helper at the exact file the coordinator +/// contends on. +#[cfg(unix)] +fn lock_file_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf { + let mut p = cache_file_path(cfg, cache_dir).into_os_string(); + p.push(".lock"); + p.into() +} + +fn seed_cache(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path, body: serde_json::Value) { + let path = cache_file_path(cfg, cache_dir); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, serde_json::to_vec(&body).unwrap()).unwrap(); +} + +// ---- acceptance matrix --------------------------------------------------- + +#[tokio::test] +async fn test_same_key_concurrent_callers_share_one_browser_attempt() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + + // Two independent sources on the same key in ONE process. The in-memory + // INFLIGHT registry coalesces them before the file lock, so this proves the + // in-process single-flight — one leader runs the browser flow, the other + // joins its published result. The genuine cross-process race is + // `test_crossprocess_two_coordinators_race_to_one_grant_and_cache`. + let a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + let b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Auto, None), + b.acquire_with_intent(AuthIntent::Auto, None), + ); + let ta = ra.expect("first caller authenticates"); + let tb = rb.expect("second caller authenticates"); + + // One browser launch, one code exchange, one shared token. + assert_eq!( + opener.call_count(), + 1, + "only one browser attempt for one key" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one authorization-code exchange" + ); + assert_eq!(ta, tb, "both callers observe the same token"); + assert_eq!(ta, "browser-token-1"); +} + +#[tokio::test] +async fn test_denied_then_auto_reads_cooldown_without_second_launch() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Deny); + + let src = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + + let first = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + first, + Err(AuthError::Denied), + "first Auto attempt is denied" + ); + assert_eq!(opener.call_count(), 1); + + // The denial wrote a cooldown; a subsequent Auto caller reads it and + // returns the recorded outcome instead of popping a second browser. + let second = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + second, + Err(AuthError::Denied), + "queued Auto caller honors the cooldown" + ); + assert_eq!( + opener.call_count(), + 1, + "cooldown suppresses the second browser launch" + ); +} + +#[tokio::test] +async fn test_userinitiated_retry_bypasses_cooldown_and_reopens() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + + // First attempt: denied, writes a cooldown. + let deny_opener = ScriptedOpener::new(Script::Deny); + let denier = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(deny_opener.clone()), + ) + .unwrap(); + assert_eq!( + denier + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await, + Err(AuthError::Denied) + ); + + // The user explicitly retries: UserInitiated bypasses (and clears) the + // cooldown and opens a fresh browser, which now succeeds. + let approve_opener = ScriptedOpener::new(Script::Approve); + let retrier = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve_opener.clone()), + ) + .unwrap(); + let token = retrier + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await + .expect("explicit retry re-launches the browser and succeeds"); + assert_eq!(token, "browser-token-1"); + assert_eq!( + approve_opener.call_count(), + 1, + "UserInitiated retry launches despite the prior cooldown" + ); + + // Cooldown cleared on success: a follow-up Auto now sees a valid token, + // never the stale denial. + let auto = retrier.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!(auto, Ok("browser-token-1".to_string())); +} + +#[tokio::test] +async fn test_distinct_hosts_do_not_inherit_cooldown() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + + // Host A is denied and records a cooldown under key A. + let deny_opener = ScriptedOpener::new(Script::Deny); + let host_a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(deny_opener.clone()), + ) + .unwrap(); + assert_eq!( + host_a.acquire_with_intent(AuthIntent::Auto, None).await, + Err(AuthError::Denied) + ); + + // Host B is a different key (different discovery URL). It must NOT inherit + // A's cooldown: an Auto caller launches its own browser and succeeds. + let approve_opener = ScriptedOpener::new(Script::Approve); + let host_b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/b", cache.path()), + Arc::new(approve_opener.clone()), + ) + .unwrap(); + let token = host_b + .acquire_with_intent(AuthIntent::Auto, None) + .await + .expect("distinct host is unaffected by another key's cooldown"); + assert_eq!(token, "browser-token-1"); + assert_eq!(approve_opener.call_count(), 1); +} + +#[tokio::test] +async fn test_browser_open_failure_is_typed_and_retryable_by_user() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + + // Every launch strategy fails: the flow reports the typed BrowserOpenFailed + // without waiting on a listener nobody will reach. + let fail_opener = ScriptedOpener::new(Script::FailToOpen); + let failing = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(fail_opener.clone()), + ) + .unwrap(); + let result = failing + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await; + assert_eq!( + result, + Err(AuthError::BrowserOpenFailed), + "a failed launch surfaces as the typed BrowserOpenFailed" + ); + assert_eq!(fail_opener.call_count(), 1); + + // A failed launch writes a cooldown, but a UserInitiated retry bypasses it + // and reopens — a transient "no browser" (e.g. race with a display coming + // up) must never wedge an explicit user sign-in. + let approve_opener = ScriptedOpener::new(Script::Approve); + let retrier = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve_opener.clone()), + ) + .unwrap(); + let token = retrier + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await + .expect("explicit retry reopens despite the prior launch failure"); + assert_eq!(token, "browser-token-1"); + assert_eq!(approve_opener.call_count(), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_headless_dead_refresh_returns_refresh_rejected_without_browser() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired token WITH a refresh token, but the server rejects the refresh + // grant (dead/rotated). A Headless caller must classify this terminally as + // RefreshRejected and never open a browser. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "Headless dead-refresh is terminal RefreshRejected" + ); + assert_eq!(opener.call_count(), 0, "Headless never opens a browser"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "the refresh grant was attempted exactly once" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_interactive_dead_refresh_converts_to_browser() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Same dead-refresh seed, but an interactive intent must fall through to a + // browser flow instead of failing terminally. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await + .expect("interactive intent recovers via the browser"); + assert_eq!(token, "browser-token-1"); + assert_eq!(opener.call_count(), 1, "interactive intent opens a browser"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_headless_expired_token_live_refresh_recovers_silently() { + let stub = spawn_stub(false).await; // refresh succeeds + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("live refresh recovers a Headless caller silently"); + assert_eq!(token, "refreshed-token-1"); + assert_eq!(opener.call_count(), 0, "no browser on a live refresh"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_interactive_login_reuses_valid_cache_without_browser() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // A still-valid cached token short-circuits interactive_login: an explicit + // sign-in should not re-prompt when a good token is already present. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "already-valid", + "refresh_token": "rt", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + src.interactive_login() + .await + .expect("interactive_login succeeds off the valid cache"); + assert_eq!( + opener.call_count(), + 0, + "a valid cached token means no browser prompt" + ); +} + +// ---- locally-fresh rejected bearer (401) recovery ------------------------ +// +// The saved-model picker's recovery path: model discovery 401s a bearer that +// still looks locally fresh (its `expires_at` is in the future) and whose +// refresh grant is dead. Passing that exact token as `rejected` makes the +// clock untrustworthy, so the acquisition must not short-circuit on the fresh +// cache. `Auto` and `UserInitiated` then convert to a browser; `Headless` +// stays terminal with `RefreshRejected`. Seeding a *future*-expiry token is +// what distinguishes this from the expired-token refresh path. + +/// Seed a not-yet-expired access token with a (dead) refresh token and return +/// the access token so the caller can pass it as `rejected`. +#[cfg(unix)] +fn seed_fresh_rejectable(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> String { + let access = "fresh-but-rejected"; + seed_cache( + cfg, + cache_dir, + json!({ + "access_token": access, + "refresh_token": "dead-refresh", + "expires_at": future_secs(), + }), + ); + access.to_string() +} + +#[cfg(unix)] +#[tokio::test] +async fn test_auto_rejected_fresh_bearer_with_dead_refresh_launches_browser() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + let rejected = seed_fresh_rejectable(&cfg, cache.path()); + + // The token is locally fresh, so without `rejected` it would be a cache + // hit and never reach the browser. Passing it as rejected forces the + // clock-based hit to fail, the dead refresh to be attempted, and an Auto + // caller to fall through to the browser. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::Auto, Some(&rejected)) + .await + .expect("Auto recovers a rejected-but-fresh bearer via the browser"); + assert_eq!(token, "browser-token-1"); + assert_eq!(opener.call_count(), 1, "Auto launches a browser to recover"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_headless_rejected_fresh_bearer_with_dead_refresh_returns_refresh_rejected() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + let rejected = seed_fresh_rejectable(&cfg, cache.path()); + + // Same locally-fresh rejected seed, but a Headless caller cannot open a + // browser: a dead refresh is terminal RefreshRejected, never a launch. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src + .acquire_with_intent(AuthIntent::Headless, Some(&rejected)) + .await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "Headless dead-refresh on a rejected fresh bearer is terminal" + ); + assert_eq!(opener.call_count(), 0, "Headless never opens a browser"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +// ---- refresh transport failures are not credential rejections ------------ +// +// A refresh that never gets a verdict from the token endpoint — a per-request +// timeout, or a 5xx — is infrastructural, not a dead credential. It must +// surface as `NetworkUnavailable` and never pop a browser or return +// `RefreshRejected`, which would misreport a transient fault as a rotated +// token and (for interactive intents) prompt a needless sign-in. + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_timeout_is_network_unavailable_not_rejected() { + // The token endpoint hangs far longer than the injected per-request HTTP + // timeout, so the refresh call times out at the transport layer with no + // verdict from the provider. A short real-time timeout is injected rather + // than pausing the clock: under `start_paused` tokio auto-advances into + // the timer while the real loopback discovery GET is still in flight, so + // discovery — not the refresh — would trip the timeout, and the refresh + // would never even be attempted. Real time keeps the timeout attached to + // the request that actually hangs, which the `refresh_grants == 1` guard + // below proves. + let stub = spawn_stub_with(RefreshMode::Hang(Duration::from_secs(30))).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired token with a refresh token: the coordinator attempts the refresh, + // which hangs past the HTTP timeout. A Headless caller must classify the + // timeout as NetworkUnavailable, not RefreshRejected. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "slow-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with_http_timeout( + cfg, + Arc::new(opener.clone()), + Duration::from_millis(300), + ) + .unwrap(); + let result = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a refresh transport timeout is infrastructural, not a rejection" + ); + assert_eq!( + opener.call_count(), + 0, + "a timed-out refresh never becomes a credential decision" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "the refresh was attempted exactly once before timing out" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_server_error_is_network_unavailable_not_rejected() { + let stub = spawn_stub_with(RefreshMode::ServerError).await; // refresh 500s + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // A 5xx is a provider-side fault, not a grant rejection: an interactive + // intent must NOT pop a browser off it, and it must surface as + // NetworkUnavailable rather than RefreshRejected. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "server-error-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a refresh 5xx is transient, not a credential rejection" + ); + assert_eq!( + opener.call_count(), + 0, + "a provider 5xx must not trigger an interactive browser fallback" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +// ---- 4xx classification: only `invalid_grant` is a dead refresh token ----- +// +// RFC 6749 §5.2 uses 400/401 token responses for several `error` codes, but +// only `invalid_grant` means the refresh token is dead. Every other 4xx — +// `invalid_request`, `invalid_client`, `unsupported_grant_type`, +// `invalid_scope`, `408`, `429` — is a request/config/transient fault a +// browser cannot repair, so it must stay infrastructural (`NetworkUnavailable`) +// and never pop a browser. The classifier keys on the OAuth error body, not +// the bare status class. + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_400_invalid_grant_is_dead_grant_not_network() { + // A 400 (not just 401) carrying `invalid_grant` is still a dead refresh + // token, so a Headless caller must classify it terminally as + // RefreshRejected — proving the decision is the body error, not the status. + let stub = spawn_stub_with(RefreshMode::ClientError( + axum::http::StatusCode::BAD_REQUEST, + "invalid_grant", + )) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "a 400 invalid_grant is a dead refresh token, not infrastructural" + ); + assert_eq!(opener.call_count(), 0, "Headless never opens a browser"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_non_invalid_grant_4xx_is_network_unavailable_not_rejected() { + // Every 4xx whose OAuth body is NOT `invalid_grant` is a request/config or + // transient fault a browser cannot repair, so it must surface as + // NetworkUnavailable and never pop a browser — even for an interactive + // intent that COULD. Two representative cases prove the classifier keys on + // the body `error`, not the status class: a 400 `invalid_request` + // (malformed/misconfigured) and a 429 `slow_down` (transient rate limit). + for (status, error, refresh_token) in [ + ( + axum::http::StatusCode::BAD_REQUEST, + "invalid_request", + "misconfigured-refresh", + ), + ( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "slow_down", + "rate-limited-refresh", + ), + ] { + let stub = spawn_stub_with(RefreshMode::ClientError(status, error)).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": refresh_token, + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a non-invalid_grant 4xx ({status} {error}) is infrastructural, not a credential rejection" + ); + assert_eq!( + opener.call_count(), + 0, + "a browser cannot repair {error}, so none is opened" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + } +} + +#[tokio::test] +async fn test_two_concurrent_userinitiated_denials_share_one_browser() { + // Two UserInitiated callers arrive together on one key. The first is the + // leader and opens the browser; the second is a pre-existing joiner that + // must receive the leader's SAME Denied result rather than acquire the + // lock afterward, clear the cooldown, and pop a second browser. This is + // the failure-sharing that a lock-alone protocol loses. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Deny); + + let a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + let b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::UserInitiated, None), + b.acquire_with_intent(AuthIntent::UserInitiated, None), + ); + assert_eq!(ra, Err(AuthError::Denied), "leader observes the denial"); + assert_eq!( + rb, + Err(AuthError::Denied), + "the joiner shares the leader's denial, not a fresh attempt" + ); + assert_eq!( + opener.call_count(), + 1, + "one browser launch shared across both concurrent UserInitiated callers" + ); +} + +// ---- mixed-intent coalescing must not leak an Auto cooldown to a user ----- +// +// `Auto` and `UserInitiated` disagree on cooldown policy: `Auto` honors a +// recorded cooldown and returns its `Denied`/`TimedOut` without a browser, +// while `UserInitiated` bypasses the cooldown and opens a fresh sign-in. If +// both coalesced onto one in-process slot, a user's explicit action arriving +// behind an `Auto` leader would inherit the leader's suppressed result and +// silently get *nothing* — no browser, no bypass. Keying the single-flight +// slot by the full intent keeps the two from sharing a slot. + +#[tokio::test] +async fn test_userinitiated_joiner_does_not_inherit_auto_cooldown_result() { + // Race an Auto caller and a UserInitiated caller on one key. `join!` polls + // the Auto future first: it becomes the in-process leader, takes the file + // lock, and opens a browser that is DENIED — and it yields on the callback + // wait while still holding the lock and its INFLIGHT slot. The + // UserInitiated caller is then polled *while the Auto attempt is in flight*. + // + // Before the fix, both intents keyed the single-flight slot by browser + // capability alone, so the UserInitiated caller joined the Auto leader's + // slot and inherited its `Denied` — never opening its own browser, never + // getting the cooldown bypass it promises. Keying by the full intent keeps + // them apart: the UserInitiated caller runs its own flow, bypasses the + // cooldown the Auto denial recorded, and signs in on its own browser. + // + // Distinct openers make the coalescing visible: if the UserInitiated caller + // had inherited the Auto result, its `approve` opener would never fire. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let deny = ScriptedOpener::new(Script::Deny); + let approve = ScriptedOpener::new(Script::Approve); + + let auto = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(deny.clone()), + ) + .unwrap(); + let user = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve.clone()), + ) + .unwrap(); + + let (auto_res, user_res) = tokio::join!( + auto.acquire_with_intent(AuthIntent::Auto, None), + user.acquire_with_intent(AuthIntent::UserInitiated, None), + ); + assert_eq!( + auto_res, + Err(AuthError::Denied), + "the Auto leader observes its own browser denial" + ); + let bearer = + user_res.expect("the UserInitiated caller runs its own sign-in, not the Auto slot"); + assert!( + bearer.starts_with("browser-token-"), + "UserInitiated got a fresh browser token, not the Auto leader's Denied: {bearer}" + ); + assert_eq!( + deny.call_count(), + 1, + "the Auto leader opened exactly one (denied) browser" + ); + assert_eq!( + approve.call_count(), + 1, + "the UserInitiated caller opened its own browser instead of inheriting the Auto denial" + ); +} + +// ---- a joiner must never inherit its own rejected token ------------------- +// +// The in-process slot is keyed by (lock path, intent) only, so a 401-recovery +// joiner shares a leader that ran with a *different* `rejected` value. If the +// leader publishes a token equal to THIS caller's rejected bytes — e.g. its +// refresh produced exactly the generation the joiner just reported 401 — the +// joiner would retry the provider with the credentials it already knows are +// dead. The joiner must instead detect the collision and run its own bounded +// acquisition, obtaining a token that differs from its `rejected`. + +#[cfg(unix)] +#[tokio::test] +async fn test_joiner_never_receives_its_own_rejected_token() { + // Two concurrent `Headless` 401-recovery callers on one key, each rejecting + // a DIFFERENT bearer. The seeded cache token is expired, so neither caller + // is satisfied by the fast path (or the under-lock re-read) — both must go + // to the live refresh grant, which is what makes the leader slow enough to + // join. `join!` polls A first: it registers the INFLIGHT slot as leader, + // takes the file lock, and yields on its refresh HTTP call while holding + // the slot. B is then polled *while A is in flight* and joins A's slot. + // + // A's refresh yields `refreshed-token-1` and saves it. That is exactly the + // bearer B passed as `rejected` (B held gen-1 and was 401'd on it). Before + // the fix, B — a joiner keyed only by intent — received A's published + // `refreshed-token-1`: the precise bytes it just reported rejected. The fix + // makes B detect `published == own rejected`, fall through to its own + // acquisition, and refresh again to `refreshed-token-2`. The rerun goes + // straight to the leader body (not back through the registry), and its + // under-lock re-read rejects A's freshly-saved gen-1 (it equals B's + // `rejected`), so B can neither re-join the dead generation's slot, adopt + // its own rejected bytes from disk, nor loop. + let stub = spawn_stub(false).await; // refresh always succeeds + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired access token with a live refresh token: the expiry forces both + // callers past the cache into the refresh grant regardless of their + // distinct `rejected` values. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("rejected-by-a")), + b.acquire_with_intent(AuthIntent::Headless, Some("refreshed-token-1")), + ); + + assert_eq!( + ra, + Ok("refreshed-token-1".to_string()), + "the leader refreshes to gen-1, which differs from its own rejected value" + ); + let b_token = rb.expect("the joiner runs its own acquisition instead of inheriting gen-1"); + assert_ne!( + b_token, "refreshed-token-1", + "the joiner must never receive the exact bytes it reported 401-rejected" + ); + assert_eq!( + b_token, "refreshed-token-2", + "the joiner refreshed once more to a token that differs from its rejected value" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "exactly two refreshes: the leader's, then the joiner's single bounded rerun — no loop" + ); + assert_eq!( + opener.call_count(), + 0, + "a live refresh recovers both callers without any browser" + ); +} + +// ---- a bounded rerun that re-issues the rejected bytes must fail typed ----- +// +// The joiner-collision fix reruns its own bounded acquisition when the leader +// publishes the joiner's own rejected token. That rerun is only safe if it, +// too, refuses to hand back the rejected bytes: a provider that re-issues an +// identical access token on refresh would otherwise let the exact 401'd +// credential escape through the rerun. The coordinator guards the refresh +// success at the persistence boundary (`finish`), so both a plain leader and +// this rerun terminate with a typed auth error before caching the rejected +// token rather than returning it. + +#[cfg(unix)] +#[tokio::test] +async fn test_joiner_rerun_reissuing_rejected_token_fails_typed_not_loop() { + // A sticky provider returns ONE fixed access token on every refresh. Leader + // A rejects a different value, so its refresh to the sticky token is a + // clean success it publishes and caches. Joiner B rejected exactly the + // sticky token: it collides with A's published result, reruns its own + // bounded acquisition, and that rerun's refresh hands back the sticky token + // again — B's own rejected bytes. The persistence-boundary guard turns that + // into a terminal `RefreshRejected` (Headless, no browser) instead of + // returning the dead credential or looping. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("sticky-token")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("rejected-by-a")), + b.acquire_with_intent(AuthIntent::Headless, Some("sticky-token")), + ); + + assert_eq!( + ra, + Ok("sticky-token".to_string()), + "the leader's refresh yields the sticky token, which differs from its own rejected value" + ); + assert_eq!( + rb, + Err(AuthError::RefreshRejected), + "the joiner's rerun re-issued its own rejected bytes and must fail typed, not return them" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "exactly two refreshes: the leader's, then the joiner's single bounded rerun — no loop" + ); + assert_eq!( + opener.call_count(), + 0, + "a headless collision never opens a browser" + ); +} + +// ---- a joiner with a DIFFERENT rejected must not inherit a rejection-relative failure --- +// +// When a leader A rejects token X (its own `rejected`) and the refresh yields +// X again — causing `finish()` to return `RefreshRejected` — that failure is +// scoped to A's specific rejected token. A joiner B waiting on the same slot +// with a *different* rejected token Y must NOT adopt that failure: the refresh +// grant of X is a perfectly valid token for B (B only rejected Y). The slot +// publishes A's rejected-token digest; B detects the mismatch and reruns its +// own `acquire_leader` — which finds X already in the cache from A's successful +// write (X was issued but not cached because A had it as `rejected`, but in +// Carl's scenario there was NO prior good token — the refresh just minted X +// which IS good for B), and returns it. +// +// Concrete scenario: A rejected X, refresh re-issues X → A gets RefreshRejected. +// B rejected Y (different), refresh would yield X for B → B succeeds. + +#[cfg(unix)] +#[tokio::test] +async fn test_joiner_with_different_rejected_does_not_inherit_leaders_rejection_failure() { + // Sticky provider always returns "X" on every refresh grant. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("X")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + // A rejected "X" (same as what the provider always issues). The refresh + // re-issues "X", `finish()` returns RefreshRejected — the failure is + // rejection-relative to A's own rejected bytes. + // + // B rejected "Y" (different). It should NOT inherit A's RefreshRejected: + // the provider can give B "X", which is valid for B. + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("X")), + b.acquire_with_intent(AuthIntent::Headless, Some("Y")), + ); + + assert_eq!( + ra, + Err(AuthError::RefreshRejected), + "A's refresh re-issued its own rejected token X — typed failure for A" + ); + assert_eq!( + rb, + Ok("X".to_string()), + "B's rejected was Y (not X), so B reruns and its refresh yields X — a valid token for B" + ); + assert_eq!( + opener.call_count(), + 0, + "headless callers never open a browser" + ); + // At least two refresh grants: A's, then B's rerun. + assert!( + stub.refresh_grants.load(Ordering::SeqCst) >= 2, + "B must have run its own refresh (rerun, not adoption)" + ); +} + +// ---- in-process joiner state reconciliation (P1 regressions) --------------- +// +// These tests drive two independently constructed same-key sources through real +// leader/joiner acquisition and verify that subsequent public reads on both +// sources reflect the shared outcome — not the stale or absent credential each +// source carried before joining. +// +// The coordinator's in-process single-flight coalesces callers on a shared +// `InflightSlot`. On the old bearer-only publication path the joiner's own +// `state` cell was never updated, so: +// - success: B's next plain `bearer()` served the locally-fresh-but-rejected +// token X rather than the just-acquired Y (memory won over disk). +// - failure: B's matching rejected X remained live; its next `bearer()` still +// served it. +// - no-persistence (Windows): B's state stayed empty; its next headless read +// returned `NoCredential` instead of Y and a second browser opened. +// +// All three tests exercise the full `finish()` → `acquire_locked()` → +// `acquire_leader()` → `LeaderGuard::complete()` → joiner wiring. + +// Unix-specific: the seed provides a live refresh token. The non-Unix constructor +// does not read the disk cache, so without a seed in memory A's headless path +// returns NoCredential rather than RefreshRejected. +#[cfg(unix)] +#[tokio::test] +async fn test_inprocess_joiner_reconciles_stale_state_after_shared_success() { + // Scenario: A and B both loaded a locally-fresh-but-401'd token X. A leads, + // refreshes to Y. B joins and wakes to Ok(Y). Without reconciliation B's + // state still holds unexpired X, so B's next plain bearer() serves X — the + // exact token the caller just reported 401-rejected. + // + // `join!` polls A first: A registers the INFLIGHT slot as leader, takes the + // file lock, and yields on the refresh HTTP call. B is polled while A is in + // flight, finds the slot, and joins. + // + // Mutation check (no state reconciliation): B.state stays Some(unexpired-X). + // The subsequent bearer() call on B hits the memory cache (X is not expired, + // rejected=None so identity check passes), and `a_next == b_next` FAILS + // because ra_next = Y and rb_next = X. + let stub = spawn_stub(false).await; // refresh returns fresh token + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::FailToOpen); // headless — no browser + let cfg = config(&stub, "/disco/a", cache.path()); + + // Unexpired X with a live refresh token: both A and B load it as their + // initial state via the constructor's `read_cache` call. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale-X", + "refresh_token": "live-refresh", + "expires_at": future_secs(), // NOT expired — locally fresh + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + // Both 401-recovery callers on the same key. A becomes leader (polled + // first), refreshes to "refreshed-token-1", B joins A's slot. + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + b.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + ); + + assert_eq!( + ra, + Ok("refreshed-token-1".to_string()), + "leader (A) receives the refreshed token" + ); + assert_eq!( + rb, + Ok("refreshed-token-1".to_string()), + "joiner (B) receives the leader's token" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "exactly one refresh — B joined A's slot rather than running its own" + ); + + // After the join, both sources must hold the new token in state. Subsequent + // plain bearer() calls (rejected=None) on both must return Y, not stale X. + let ra_next = a + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("A subsequent read must return the refreshed token"); + let rb_next = b + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("B subsequent read must return the refreshed token, not stale X"); + + assert_eq!(ra_next, "refreshed-token-1", "A subsequent read returns Y"); + assert_eq!( + rb_next, "refreshed-token-1", + "B subsequent read returns Y, not stale X — \ + mutation check: fails if joiner state was not reconciled (bearer-only publication)" + ); + // No second refresh: both subsequent reads hit the in-memory cache. + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "subsequent reads hit the in-memory cache — no second network call" + ); +} + +// Unix-specific: refresh token is required for a headless rejection path. +#[cfg(unix)] +#[tokio::test] +async fn test_inprocess_joiner_neutralizes_rejected_on_matching_shared_failure() { + // Scenario: A and B both carry unexpired X as their rejected token. A leads, + // attempts a refresh, gets 401 (RefreshRejected). B joins and wakes to the + // shared failure. Without reconciliation B's state still holds unexpired X, + // so B's next plain bearer() serves it — the rejected credential reappears. + // + // With reconciliation, expire_rejected is called under lock, so X is + // force-expired in B's state and cannot be served again. + // + // Mutation check (no expire_rejected call on the joiner Err path): B.state + // still holds unexpired X after the join. B's next bearer() (rejected=None) + // hits the memory cache and returns X. The assertion `rb_next != Ok("stale-X")` + // FAILS — the rejected credential reappears. + let stub = spawn_stub(true).await; // reject_refresh=true → 401 on every refresh + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::FailToOpen); // headless — no browser + let cfg = config(&stub, "/disco/a", cache.path()); + + // Unexpired X with a live (but destined-to-be-rejected) refresh token. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale-X", + "refresh_token": "live-refresh", + "expires_at": future_secs(), // NOT expired — locally fresh + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + b.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + ); + + assert_eq!( + ra, + Err(AuthError::RefreshRejected), + "leader (A) gets RefreshRejected — dead refresh" + ); + assert_eq!( + rb, + Err(AuthError::RefreshRejected), + "joiner (B) shares the leader's RefreshRejected failure" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "exactly one refresh attempt — B joined the failure rather than retrying" + ); + + // After the shared failure, B must not be able to serve stale X on a + // subsequent plain bearer() call. Without reconciliation, B.state still + // holds unexpired X and the next bearer() would return it. + let rb_next = b.acquire_with_intent(AuthIntent::Headless, None).await; + assert_ne!( + rb_next, + Ok("stale-X".to_string()), + "B must not serve the rejected token after adopting a matching shared failure — \ + mutation check: fails if the joiner Err path skips expire_rejected" + ); +} + +// Non-Unix-specific: disk persistence is disabled on Windows, so the only way +// for B to retain Y after joining is in-memory state reconciliation. On Unix +// the disk can provide Y as a fallback, masking a reconciliation failure. +#[cfg(not(unix))] +#[tokio::test] +async fn test_inprocess_joiner_populates_empty_state_no_second_acquisition() { + // Scenario: A and B both start with empty state (no disk token on non-Unix). + // A leads, opens a browser, exchanges the code for Y. B joins A's slot and + // wakes to Ok(Y). Without reconciliation, B.state stays None. B's next + // headless acquire returns NoCredential instead of Y, and a second browser + // would open if UserInitiated. + // + // Mutation check (no state reconciliation): B.state stays None. The + // subsequent headless acquire on B returns Err(NoCredential) instead of + // Ok("browser-token-1") — the assertion FAILS. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let approve = ScriptedOpener::new(Script::Approve); + + let a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve.clone()), + ) + .unwrap(); + let b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve.clone()), + ) + .unwrap(); + + // Both start with empty state — UserInitiated falls through to a browser. + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::UserInitiated, None), + b.acquire_with_intent(AuthIntent::UserInitiated, None), + ); + + assert_eq!( + ra, + Ok("browser-token-1".to_string()), + "leader (A) gets the browser token" + ); + assert_eq!( + rb, + Ok("browser-token-1".to_string()), + "joiner (B) shares the leader's browser token" + ); + assert_eq!( + approve.call_count(), + 1, + "exactly one browser opened — B joined rather than launching its own" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one authorization-code exchange" + ); + + // B's subsequent headless acquire must return Y from in-memory state without + // a second browser. Without reconciliation, B.state is None and headless + // returns NoCredential (no disk fallback on non-Unix). + let rb_next = b + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect( + "B subsequent headless read must return Y from in-memory state, not NoCredential — \ + mutation check: fails if joiner state was not reconciled (bearer-only publication)", + ); + assert_eq!( + rb_next, "browser-token-1", + "B retains Y in memory for subsequent headless reads" + ); + // No second browser: B's subsequent read hit the in-memory cache. + assert_eq!( + approve.call_count(), + 1, + "no second browser opened — B's subsequent headless read hit the in-memory cache" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "no second code exchange" + ); +} + +// ---- a browser success that re-issues the rejected bytes must fail typed --- +// +// The 401-recovery invariant lives at `finish`'s persistence boundary, so it +// must hold on the browser-success path too — not just refresh. An +// interactive caller whose refresh is dead falls through to a browser sign-in; +// if that exchange re-issues the exact token the caller reported 401-rejected +// (a provider reusing an access token within its validity window), the guard +// must terminate typed before caching it rather than hand back the dead +// bearer. A single interactive leader exercises the path; the colliding-joiner +// rerun routes through the same boundary. + +#[cfg(unix)] +#[tokio::test] +async fn test_interactive_browser_reissuing_rejected_token_fails_typed_not_loop() { + // Refresh 401s (dead), so an interactive intent falls through to the + // browser; the exchange stickily returns one fixed token on every grant. + let stub = spawn_stub_with_modes( + RefreshMode::Reject, + ExchangeMode::SucceedSticky("sticky-browser"), + ) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired seed with a (dead) refresh token: the caller misses the cache, + // its refresh is rejected, and it browses. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + + // The caller reports the sticky browser token as its rejected bearer, so + // the browser exchange hands back exactly those bytes. + let result = src + .acquire_with_intent(AuthIntent::UserInitiated, Some("sticky-browser")) + .await; + + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a browser success equal to the rejected bytes must fail typed, not return them" + ); + assert_eq!( + opener.call_count(), + 1, + "the interactive attempt browsed exactly once — no loop re-launching the browser" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one code exchange — the guard fails terminally instead of retrying" + ); +} + +// ---- a rejected re-issue must not poison the cache for later callers ------- +// +// The persistence-boundary guard's whole purpose: a rejected-aware acquisition +// that a provider answers with the exact 401'd bytes must not leave those bytes +// cached as fresh. Before the fix, `finish()` persisted first and the guard +// fired after, so the dead token survived on disk and in memory — the next +// plain `bearer()` (`rejected = None`) and any freshly constructed source would +// serve it straight from the cache with no re-validation. These two regressions +// prove the cache is untouched after the typed failure, on both the refresh and +// the browser re-issue paths. + +#[cfg(unix)] +#[tokio::test] +async fn test_sticky_refresh_rejection_does_not_poison_cache_for_later_callers() { + // A sticky provider re-issues `sticky-token` on every refresh. A caller that + // reports `sticky-token` as its rejected bearer gets a typed failure — and + // the rejected bytes must never reach the cache. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("sticky-token")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::Headless, Some("sticky-token")) + .await; + assert_eq!( + rejected, + Err(AuthError::RefreshRejected), + "a refresh that re-issues the rejected bytes fails typed" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // The rejected bytes were never persisted: a fresh process reading the same + // cache path finds the original expired seed, not `sticky-token`. + let on_disk = std::fs::read_to_string(cache_file_path(&cfg, cache.path())).unwrap(); + assert!( + on_disk.contains("expired-seed") && !on_disk.contains("sticky-token"), + "the failed acquisition poisoned the on-disk cache: {on_disk}" + ); + + // A freshly constructed source over the same cache must therefore refresh + // over the network to obtain the token — it cannot serve a cached poison. + // Under the bug this was a lock-free cache hit and `refresh_grants` stayed + // at 1; the fix forces a second refresh. `Headless, None` is the plain + // `bearer()` path (rejected = None) with the typed error surfaced directly. + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = fresh + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("a rejected=None caller legitimately obtains the current token"); + assert_eq!(token, "sticky-token"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "the fresh source re-validated over the network — it did not serve a cached poison" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_sticky_browser_rejection_does_not_poison_cache_for_later_callers() { + // Refresh is dead, so an interactive caller browses; the exchange stickily + // re-issues `sticky-browser`. A caller reporting those bytes as rejected + // gets a typed failure, and the dead token must never reach the cache. + let stub = spawn_stub_with_modes( + RefreshMode::Reject, + ExchangeMode::SucceedSticky("sticky-browser"), + ) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::UserInitiated, Some("sticky-browser")) + .await; + assert_eq!( + rejected, + Err(AuthError::NetworkUnavailable), + "a browser exchange that re-issues the rejected bytes fails typed" + ); + assert_eq!(opener.call_count(), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); + + // The rejected bytes were never persisted: the on-disk cache still holds + // the expired seed, so no fresh process can restore `sticky-browser`. + let on_disk = std::fs::read_to_string(cache_file_path(&cfg, cache.path())).unwrap(); + assert!( + on_disk.contains("expired-seed") && !on_disk.contains("sticky-browser"), + "the failed browser acquisition poisoned the on-disk cache: {on_disk}" + ); + + // A subsequent plain `bearer()` (Headless, `rejected = None`) reads that + // un-poisoned cache: the seed is expired and its refresh is dead, so it + // fails `RefreshRejected` — it never serves `sticky-browser` from cache. + // Under the bug the poisoned cache made this a hit returning the dead bytes. + let fresh_opener = ScriptedOpener::new(Script::Approve); + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(fresh_opener.clone())).unwrap(); + let later = fresh.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + later, + Err(AuthError::RefreshRejected), + "a fresh source must not serve the rejected browser token from cache" + ); + assert_eq!( + fresh_opener.call_count(), + 0, + "a headless caller never browses" + ); +} + +// ---- a 401 on a locally-fresh token neutralizes the cached copy ----------- +// +// P1: the persistence-boundary guard refuses to *save* a re-issued rejected +// token, but the ORIGINAL cached copy — the exact bytes the provider just +// 401'd — is untouched. Because `is_expired` trusts only the clock, a later +// plain `bearer()` (`rejected = None`) or a freshly constructed source would +// serve that dead token straight from cache. `expire_rejected` force-expires +// the cached copy (memory and disk) under the lock the moment a caller reports +// it rejected, so no future caller and no fresh process can serve it, while the +// refresh token — not rejected, and the engine of recovery — stays intact. + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_fresh_token_is_neutralized_for_a_fresh_process() { + // The cached access token `A` is locally UNEXPIRED, and the provider + // stickily re-issues `A` on refresh. A caller reports `A` as rejected: the + // refresh hands back `A`, the guard fails typed without persisting it — and + // the original unexpired `A` must not survive on disk for a fresh process. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::Headless, Some("A")) + .await; + assert_eq!( + rejected, + Err(AuthError::RefreshRejected), + "a refresh that re-issues the rejected bytes fails typed" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // The on-disk copy of `A` was force-expired in place: the refresh token is + // preserved, but the access token's expiry is neutralized so no clock-based + // read can serve it. Under the bug it stayed at its future expiry. + let on_disk: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(cache_file_path(&cfg, cache.path())).unwrap(), + ) + .unwrap(); + assert_eq!( + on_disk["access_token"], "A", + "the entry is kept, not deleted" + ); + assert_eq!( + on_disk["refresh_token"], "live-refresh", + "the refresh token — not rejected — survives for recovery" + ); + assert_eq!( + on_disk["expires_at"], 0, + "the rejected access token was force-expired on disk" + ); + + // A freshly constructed source reading that cache must NOT serve `A` from + // the clock: it sees the neutralized entry as expired and refreshes over + // the network. Under the bug this was a lock-free cache hit returning the + // dead `A` with `refresh_grants` frozen at 1. + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = fresh + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("a rejected=None caller obtains the provider's current token"); + assert_eq!(token, "A"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "the fresh source re-validated over the network — it did not serve the neutralized cache" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_fresh_token_is_neutralized_for_the_same_source() { + // The in-memory layer of the same neutralization: after the SAME source + // fails a 401-recovery on unexpired `A`, its next plain `bearer()` + // (`rejected = None`) must not serve `A` from the in-memory cell — it must + // re-validate. `A` is sticky, so recovery returns `A` again, but only after + // a real refresh grant (the discriminator: 1 cache hit vs. 2 grants). + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + assert_eq!( + src.acquire_with_intent(AuthIntent::Headless, Some("A")) + .await, + Err(AuthError::RefreshRejected), + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // Same source, plain bearer: the in-memory `A` was neutralized, so this is + // a miss that refreshes rather than a cache hit. Under the bug the + // unexpired in-memory `A` was served directly and `refresh_grants` stayed 1. + let token = src + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("a subsequent plain bearer re-validates rather than serving the dead token"); + assert_eq!(token, "A"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "the same source re-validated in memory — it did not serve the neutralized token" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_fresh_token_neutralized_when_recovery_browses() { + // The browser variant: `A` is unexpired but its refresh token is dead, so + // an interactive 401-recovery falls through to the browser, whose exchange + // stickily re-issues `A`. The guard fails typed without persisting it, and + // the neutralized `A` must not survive for a later headless caller. + let stub = spawn_stub_with_modes(RefreshMode::Reject, ExchangeMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "dead-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::UserInitiated, Some("A")) + .await; + assert_eq!( + rejected, + Err(AuthError::NetworkUnavailable), + "a browser exchange that re-issues the rejected bytes fails typed" + ); + assert_eq!(opener.call_count(), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); + + // The unexpired `A` was force-expired on disk, so a fresh headless source + // finds it unusable and — its refresh being dead — fails `RefreshRejected` + // rather than serving `A`. Under the bug the still-fresh `A` was a cache + // hit that returned the dead token. + let fresh_opener = ScriptedOpener::new(Script::Approve); + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(fresh_opener.clone())).unwrap(); + let later = fresh.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + later, + Err(AuthError::RefreshRejected), + "a fresh source must not serve the neutralized rejected token from cache" + ); + assert_eq!( + fresh_opener.call_count(), + 0, + "a headless caller never browses" + ); +} + +// ---- P1-1 bounded three-stage neutralization: disk fallback paths ----------- +// +// `expire_rejected()` neutralizes the on-disk token with three-stage fallback: +// 1. Atomic rewrite via `persist()` (temp-file + rename, owner-only perms). +// 2. In-place truncating overwrite via `OpenOptions::write().truncate(true)` — +// succeeds even when the parent directory is non-writable, because only the +// file's own mode matters for writing an existing file. +// 3. `remove_file` as a last resort. +// +// The primary case this tests: a 0600 token file under a 0500 parent directory. +// Temp-file creation (for the atomic path) fails with EACCES; the in-place +// write succeeds because the file itself is owner-writable. After the in-place +// overwrite the file still exists but carries `expires_at = 0`, so a later +// plain `bearer(None)` or a freshly constructed source reads the now-expired +// entry and re-validates over the network instead of serving the dead token. + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_token_disk_neutralization_neutralizes_in_place_when_parent_blocks_rewrite() { + use std::os::unix::fs::PermissionsExt as _; + + // Seed unexpired `A` with a live refresh. The provider stickily re-issues `A`. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Create the token file inside a dedicated subdirectory so we can chmod + // just that subdirectory non-writable without affecting the test harness. + let token_dir = cache.path().join("protected"); + std::fs::create_dir_all(&token_dir).unwrap(); + + // Override the config to use the protected subdir. + let cfg = PkceOAuthConfig { + cache_dir_override: Some(token_dir.clone()), + ..cfg + }; + let cache_file = cache_file_path(&cfg, &token_dir); + + seed_cache( + &cfg, + &token_dir, + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + // Build the source: it reads `A` from disk into its in-memory cell. + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + // The token file lives at `token_dir/databricks/.json`. Its direct + // parent is `token_dir/databricks/`, not `token_dir` itself — the + // coordinator's `cache_path_for()` appends the namespace subdir. Assert + // the relationship explicitly so a future path-resolution change breaks + // loudly here instead of silently letting the atomic write succeed (which + // would make the test vacuously pass even without the in-place fallback). + let protected_dir = cache_file + .parent() + .expect("cache file must have a parent directory"); + assert_eq!( + protected_dir, + token_dir.join("databricks"), + "cache file's direct parent is token_dir/databricks, not token_dir" + ); + + // Pre-create the advisory lock file so `acquire_auth_lock` can open it + // even after the directory is made non-writable. The lock file must exist + // before the chmod, because `OpenOptions::create(true)` on an existing + // file succeeds regardless of parent-dir permissions, while creating a new + // file in a 0500 directory would EACCES. + let lock_file = { + let mut p = cache_file.as_os_str().to_owned(); + p.push(".lock"); + std::path::PathBuf::from(p) + }; + std::fs::File::create(&lock_file).expect("pre-create lock file before chmod"); + + // Make the direct parent non-writable (0500): temp-file creation for the + // atomic persist requires creating a new file in this directory → EACCES. + // The file itself remains 0600 owner-writable, so the in-place fallback + // path in `expire_rejected` can still open and truncate it. + std::fs::set_permissions(protected_dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + + // Trigger 401-recovery: refresh stickily re-issues `A`, `finish()` rejects + // it typed. `expire_rejected` runs: atomic persist fails (EACCES on parent), + // in-place write succeeds (file mode 0600). + let result = src + .acquire_with_intent(AuthIntent::Headless, Some("A")) + .await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "typed failure returned; neutralization does not disrupt the recovery path" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // Restore write permission so the test harness can clean up. + std::fs::set_permissions(protected_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + + // The cache file still exists (in-place write, not removal), but its + // `expires_at` should now be 0 — it was overwritten in-place. + assert!( + cache_file.is_file(), + "in-place fallback: file still exists (not removed)" + ); + let raw = std::fs::read(&cache_file).expect("cache file readable after in-place write"); + let cached: serde_json::Value = + serde_json::from_slice(&raw).expect("cache file parseable after in-place write"); + assert_eq!( + cached.get("expires_at").and_then(|v| v.as_u64()), + Some(0), + "in-place write set expires_at = 0: token is now expired on disk" + ); + + // A fresh source constructed after the neutralization must not serve `A`. + let fresh_src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + // The disk token is expired; bearer() falls through to refresh, which + // stickily re-issues `A`, which `finish()` rejects again (no rejected + // identity on this plain call — the disk is now expired, so the source + // enters the refresh path, gets `A` back from the provider, and `finish()` + // sees no rejection guard and would persist it). But with no `rejected` + // passed here, a plain `bearer()` with the now-expired disk entry must + // re-validate. If the in-place write succeeded, the disk token has + // expires_at = 0 and `cached_hit` skips it, so the source goes to refresh. + // We confirm `A` is not served as a cache hit: the stub records a second + // refresh grant. + let _ = fresh_src + .acquire_with_intent(AuthIntent::Headless, None) + .await; + assert!( + stub.refresh_grants.load(Ordering::SeqCst) >= 2, + "fresh source did not serve `A` as a plain cache hit — it re-validated over the network" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_token_in_memory_neutralized_when_disk_neutralization_skipped() { + // When `expire_rejected()` cannot read a matching disk entry (e.g. the cache + // path is not a readable regular file), the disk layer is not neutralized, + // but the IN-MEMORY layer is always neutralized unconditionally. This test + // proves the in-memory safety path: even without disk neutralization, a + // subsequent plain `bearer()` on the same source cannot serve the dead token + // from the in-memory cell. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + let cache_file = cache_file_path(&cfg, cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + // Replace the cache file with a directory so `read_private_cache` inside + // `expire_rejected` returns None (EISDIR on open). The disk branch is + // skipped entirely — only the in-memory layer is neutralized. + std::fs::remove_file(&cache_file).unwrap(); + std::fs::create_dir_all(&cache_file).unwrap(); + + let result = src + .acquire_with_intent(AuthIntent::Headless, Some("A")) + .await; + assert_eq!(result, Err(AuthError::RefreshRejected)); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // In-memory layer: force-expired. The same source's next plain bearer() + // must not serve `A` from the in-memory cell. + let next = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "in-memory `A` was force-expired; same source went to the network rather than serving the dead token" + ); + // The sticky refresh obtained `A` from the network (grant #2). The persist() + // call fails because the cache path is now a directory — save() maps the + // persist failure to NetworkUnavailable. This proves: (a) the in-memory + // neutralization worked (the source re-validated rather than serving A from + // the expired in-memory cell), and (b) the network was reached. The + // NetworkUnavailable result is an expected artifact of the directory-as- + // cache-path test setup, not a correctness gap. + assert!( + matches!(next, Err(AuthError::NetworkUnavailable)), + "save() fails with NetworkUnavailable on persist failure (expected artifact of test setup)" + ); + assert_ne!( + next, + Ok("A".to_owned()), + "A was not served from the expired in-memory cell — network was reached" + ); + + // Cleanup the directory we created. + std::fs::remove_dir(&cache_file).ok(); +} + +// ---- expired-sibling replacement must not satisfy a 401 recovery ---------- +// +// After a 401, `rejected = Some(t)` makes the expiry clock untrustworthy, so a +// cache hit requires a token that both DIFFERS from `t` and is still unexpired. +// An expired sibling token — one that merely differs from the rejected bytes — +// must NOT be served as the replacement: doing so would skip the refresh the +// 401 demanded and hand back a token the provider will also reject. + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_recovery_skips_expired_sibling_and_refreshes() { + let stub = spawn_stub(false).await; // refresh succeeds + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // The cached token is a DIFFERENT string from the rejected bytes, but it is + // expired. Under the old "differs is enough" rule it would be returned as + // the sibling replacement; the fix requires it to be unexpired too, so the + // coordinator must fall through to the live refresh instead. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-sibling", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::Headless, Some("rejected-original")) + .await + .expect("an expired sibling forces a refresh rather than being reused"); + assert_eq!( + token, "refreshed-token-1", + "the expired sibling was not accepted; a fresh token was obtained" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "the 401 recovery refreshed instead of reusing the expired sibling" + ); + assert_eq!(opener.call_count(), 0, "a live refresh needs no browser"); +} + +// ---- code-exchange classifier: rejection vs. infrastructure -------------- +// +// The browser code exchange must mirror the refresh classifier: only a 4xx +// `invalid_grant` establishes the authorization code was rejected (terminal, +// cooldown-worthy `ExchangeFailed`). A 429, any 5xx, and a malformed 2xx are a +// transient provider fault that must surface as `NetworkUnavailable` — never +// poisoning the 5-minute cooldown against a provider outage after callback. + +#[tokio::test] +async fn test_exchange_invalid_grant_is_exchange_failed_and_cools_down() { + let stub = spawn_stub_with_exchange(ExchangeMode::Fail( + axum::http::StatusCode::UNAUTHORIZED, + "invalid_grant", + )) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // A genuinely rejected code is terminal ExchangeFailed and is + // cooldown-worthy: a following Auto caller reads the cooldown without a + // second browser. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let first = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + first, + Err(AuthError::ExchangeFailed), + "a 401 invalid_grant on the code exchange is a rejected grant" + ); + assert_eq!(opener.call_count(), 1); + + let second = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + second, + Err(AuthError::ExchangeFailed), + "the rejected exchange wrote a cooldown the next Auto caller honors" + ); + assert_eq!( + opener.call_count(), + 1, + "the cooldown suppressed a second browser launch" + ); +} + +#[tokio::test] +async fn test_exchange_transient_faults_are_network_unavailable_not_cooldown() { + // A 429, a 500, and a malformed 2xx are provider faults, not rejected + // codes: each must surface as NetworkUnavailable and leave no cooldown, so + // a subsequent Auto caller retries with a fresh browser rather than + // inheriting a suppressed outcome. + let cases = [ + ExchangeMode::Fail(axum::http::StatusCode::TOO_MANY_REQUESTS, "slow_down"), + ExchangeMode::Fail( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "temporarily_unavailable", + ), + ExchangeMode::MalformedSuccess, + ]; + for exchange in cases { + let stub = spawn_stub_with_exchange(exchange).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a transient exchange fault is infrastructural, not a rejected code" + ); + assert_eq!(opener.call_count(), 1); + + // No cooldown was written, so a second Auto caller launches again + // rather than reading a suppressed outcome. + let retry = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + retry, + Err(AuthError::NetworkUnavailable), + "a transient exchange fault leaves no cooldown to suppress the retry" + ); + assert_eq!( + opener.call_count(), + 2, + "no cooldown means the next Auto caller opens a fresh browser" + ); + } +} + +#[tokio::test] +async fn test_exchange_timeout_is_network_unavailable_not_cooldown() { + // The code exchange hangs far longer than the injected per-request HTTP + // timeout, so the exchange POST times out at the transport layer with no + // verdict from the provider — the transport branch the classifier maps to + // NetworkUnavailable. Like the refresh-timeout test, a short real-time + // timeout is injected rather than pausing the clock: under `start_paused` + // tokio would auto-advance into the timer while the real loopback + // discovery/authorize round-trips are still in flight, tripping the timeout + // on the wrong request. Real time keeps the timeout attached to the + // exchange that actually hangs. + let stub = spawn_stub_with_exchange(ExchangeMode::Hang(Duration::from_secs(30))).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + let src = PkceOAuthTokenSource::new_with_http_timeout( + cfg, + Arc::new(opener.clone()), + Duration::from_millis(300), + ) + .unwrap(); + let result = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "an exchange transport timeout is infrastructural, not a rejected code" + ); + assert_eq!(opener.call_count(), 1); + + // The timed-out exchange wrote no cooldown, so a second Auto caller launches + // its own browser rather than inheriting a suppressed outcome. + let retry = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + retry, + Err(AuthError::NetworkUnavailable), + "an exchange transport timeout leaves no cooldown to suppress the retry" + ); + assert_eq!( + opener.call_count(), + 2, + "no cooldown means the next Auto caller opens a fresh browser" + ); +} + +// ---- genuine cross-process lock contention and crash release ------------- +// +// The single-flight guarantee and its crash-release property are cross-process +// claims, so they need a real second process — not a second in-process handle — +// on the same lock file. The `lock-holder` helper binary takes the +// coordinator's advisory lock and holds it until killed; killing it models a +// crash mid-flow, and the kernel's release of the advisory lock is what lets +// the coordinator's successor proceed with no PID files and no lock breaking. + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_lock_holder_blocks_then_crash_release_lets_successor_proceed() { + let stub = spawn_stub(false).await; // refresh succeeds once the lock is free + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired token with a LIVE refresh: a cache miss forces the coordinator + // onto the slow path (it must take the lock), and once the lock is free the + // refresh recovers a token without any browser — so success is a clean + // signal that the successor proceeded. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let lock_path = lock_file_path(&cfg, cache.path()); + let ready_marker = cache.path().join("holder.ready"); + + // A real second process grabs the lock and holds it. + let mut holder = tokio::process::Command::new(env!("CARGO_BIN_EXE_lock-holder")) + .env("LOCK_HELPER_PATH", &lock_path) + .env("LOCK_HELPER_READY", &ready_marker) + .kill_on_drop(true) + .spawn() + .expect("spawn the lock-holder helper process"); + + // Synchronize on real lock ownership before racing the coordinator. + for _ in 0..600 { + if ready_marker.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + ready_marker.exists(), + "lock-holder never signaled that it holds the lock" + ); + + // The coordinator cannot make progress while another process holds the + // lock: it polls the advisory lock rather than stealing it. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let task = + tokio::spawn(async move { src.acquire_with_intent(AuthIntent::Headless, None).await }); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + !task.is_finished(), + "coordinator must block while a live process holds the cross-process lock" + ); + + // Kill the holder: the kernel releases the advisory lock on process death, + // with no PID file inspection or lock breaking on our side. + holder.kill().await.expect("kill the lock holder"); + holder.wait().await.ok(); + + let token = task + .await + .expect("acquisition task joins") + .expect("successor proceeds once the crashed holder's lock is released"); + assert_eq!( + token, "refreshed-token-1", + "successor completes the refresh after acquiring the freed lock" + ); + assert_eq!( + opener.call_count(), + 0, + "Headless successor recovers via refresh without a browser" + ); +} + +// ---- genuine cross-process coordinator races ----------------------------- +// +// The `auth-worker` helper is a real second process running the PUBLIC +// coordinator API against the shared cache. Unlike two in-process handles +// (which the `INFLIGHT` registry coalesces before the file lock), these +// workers contend on the OS advisory lock and share success through the +// on-disk cache exactly as two Buzz processes on one machine would. + +/// A spawned `auth-worker`: its child handle plus the file it writes its JSON +/// outcome to. +struct Worker { + child: tokio::process::Child, + result_path: std::path::PathBuf, +} + +#[derive(Deserialize)] +struct WorkerOutcome { + result: String, + #[cfg(unix)] + bearer: Option, + launches: u64, +} + +impl Worker { + /// Block until the worker exits, then parse its outcome file. + async fn join(mut self) -> WorkerOutcome { + let status = self.child.wait().await.expect("auth-worker joins"); + assert!( + status.success(), + "auth-worker exited with failure: {status}" + ); + let body = std::fs::read(&self.result_path).expect("auth-worker wrote its outcome"); + serde_json::from_slice(&body).expect("auth-worker outcome parses") + } +} + +/// Spawn an `auth-worker` child against `cfg`'s shared cache. `extra` sets the +/// optional barrier-marker env vars ((name, path) pairs) a scenario needs to +/// order events across processes. +fn spawn_worker( + cfg: &PkceOAuthConfig, + cache_dir: &std::path::Path, + intent: &str, + script: &str, + tag: &str, + extra: &[(&str, &std::path::Path)], +) -> Worker { + let result_path = cache_dir.join(format!("{tag}.result.json")); + let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_auth-worker")); + cmd.env("AUTH_WORKER_DISCOVERY_URL", &cfg.discovery_url) + .env("AUTH_WORKER_CACHE_DIR", cache_dir) + .env("AUTH_WORKER_NAMESPACE", &cfg.cache_namespace) + .env("AUTH_WORKER_CLIENT_ID", &cfg.client_id) + .env("AUTH_WORKER_SCOPES", cfg.scopes.join(",")) + .env("AUTH_WORKER_INTENT", intent) + .env("AUTH_WORKER_SCRIPT", script) + .env("AUTH_WORKER_RESULT", &result_path) + .kill_on_drop(true); + for (key, path) in extra { + cmd.env(key, path); + } + let child = cmd.spawn().expect("spawn the auth-worker helper process"); + Worker { child, result_path } +} + +async fn wait_for_marker(path: &std::path::Path, what: &str) { + for _ in 0..1000 { + if path.exists() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("timed out waiting for {what} ({})", path.display()); +} + +#[tokio::test] +async fn test_crossprocess_userinitiated_denial_shared_with_waiting_auto() { + // Two real processes on one key. The child runs a UserInitiated flow that + // is denied; while it holds the lock and its browser is open, the parent's + // Auto coordinator is already WAITING on the cross-process lock. The child + // must be released only once the parent is queued, so the denial the child + // records is what the waiting Auto observes — one launch total, durable + // Denied for both, across a genuine process boundary. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + let launched = cache.path().join("child.launched"); + let proceed = cache.path().join("child.proceed"); + let child = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "denier", + &[ + ("AUTH_WORKER_LAUNCHED_MARKER", launched.as_path()), + ("AUTH_WORKER_PROCEED_MARKER", proceed.as_path()), + ], + ); + + // Wait until the child holds the lock and has opened its (scripted) + // browser; its callback is withheld until we create `proceed`. + wait_for_marker(&launched, "child browser launch").await; + + // The parent's Auto coordinator now contends for the same lock. It cannot + // proceed while the child holds it, so it is a genuine cross-process + // waiter. + let parent = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(ScriptedOpener::new(Script::Approve)), + ) + .unwrap(); + let auto = + tokio::spawn(async move { parent.acquire_with_intent(AuthIntent::Auto, None).await }); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + !auto.is_finished(), + "parent Auto must block while the child process holds the lock" + ); + + // Release the child's callback: it finishes the denial and writes the + // cooldown sidecar, then drops the lock. + std::fs::write(&proceed, b"go").unwrap(); + + let child_outcome = child.join().await; + assert_eq!( + child_outcome.result, "denied", + "child UserInitiated is denied" + ); + assert_eq!(child_outcome.launches, 1, "child opens exactly one browser"); + + let auto_result = auto.await.expect("parent Auto task joins"); + assert_eq!( + auto_result, + Err(AuthError::Denied), + "the already-waiting Auto reads the child's durable denial" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 0, + "a denied flow never reaches the code exchange" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_two_coordinators_race_to_one_grant_and_cache() { + // Two real coordinator processes race on one key from a cold cache. They + // are released together (via a shared start marker) so both contend for the + // lock. Exactly one wins the browser flow and performs the single code + // grant; the other serializes behind the lock and adopts the winner's token + // from the shared cache. Both must observe the same bearer, and the private + // cache must hold exactly one parseable token artifact. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + let ready_a = cache.path().join("a.ready"); + let ready_b = cache.path().join("b.ready"); + let start = cache.path().join("start"); + + let worker_a = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "approve", + "a", + &[ + ("AUTH_WORKER_READY_MARKER", ready_a.as_path()), + ("AUTH_WORKER_START_MARKER", start.as_path()), + ], + ); + let worker_b = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "approve", + "b", + &[ + ("AUTH_WORKER_READY_MARKER", ready_b.as_path()), + ("AUTH_WORKER_START_MARKER", start.as_path()), + ], + ); + + // Both processes are built and about to acquire; release them together. + wait_for_marker(&ready_a, "worker A ready").await; + wait_for_marker(&ready_b, "worker B ready").await; + std::fs::write(&start, b"go").unwrap(); + + let (out_a, out_b) = tokio::join!(worker_a.join(), worker_b.join()); + assert_eq!(out_a.result, "ok", "worker A authenticates"); + assert_eq!(out_b.result, "ok", "worker B authenticates"); + let bearer_a = out_a.bearer.expect("worker A returns a bearer"); + let bearer_b = out_b.bearer.expect("worker B returns a bearer"); + assert_eq!( + bearer_a, bearer_b, + "both processes observe the same bearer from the shared cache" + ); + + // Exactly one browser launch and one code exchange across both processes. + assert_eq!( + out_a.launches + out_b.launches, + 1, + "exactly one browser launch across the two coordinator processes" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one authorization-code exchange across both processes" + ); + + // The private cache holds exactly one parseable token artifact carrying the + // shared bearer. + let cache_path = cache_file_path(&cfg, cache.path()); + let raw = std::fs::read(&cache_path).expect("cache file exists"); + let cached: serde_json::Value = + serde_json::from_slice(&raw).expect("cache holds one parseable token artifact"); + assert_eq!( + cached.get("access_token").and_then(|v| v.as_str()), + Some(bearer_a.as_str()), + "the cached token is the shared bearer" + ); +} + +// ---- cross-process failure single-flight (attempt-record protocol) -------- +// +// `INFLIGHT` coalesces same-key callers within one process before they reach +// the file lock, so two separate processes both queued on the lock do NOT +// share the in-process registry. Without the attempt-record protocol, a +// process that acquires the lock AFTER the holder fails would re-run the +// full flow from scratch — a second browser launch on `Denied`, or a second +// dead-refresh call on `RefreshRejected`. The attempt sidecar lets the +// second process detect that the predecessor completed while it was waiting +// and adopt its failure directly. + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_waiting_headless_adopts_predecessor_refresh_rejected() { + // Two real headless processes on one key. The cache holds an expired + // token with a dead refresh. A wins the lock and calls the stub; the + // stub holds A's response so B can deterministically snapshot gen=0 + // and queue on the lock before A completes. Once B's snapshot marker + // fires, A is released: it gets `invalid_grant`, writes the attempt + // sidecar (gen=1), and releases the lock. B acquires the lock, sees + // gen=1 > snap=0, and adopts `RefreshRejected` — ONE refresh grant + // total across both processes. + // + // This replaces the prior simultaneous-start design, which was not + // deterministic: the instant-reject stub could complete A before B + // ever snapshotted, giving B snap=1 and causing a spurious second + // refresh grant. + let (stub, gate) = spawn_stub_with_held_refresh(HeldRefreshResponse::Reject).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Seed the shared cache: expired token with a dead refresh, so both + // workers fall through to the refresh grant rather than a cache hit. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let snapshot_b = cache.path().join("b.snapshot"); + + // ---- Phase 1: spawn A. It acquires the lock and immediately calls the + // stub's refresh endpoint; the stub holds the response. + let worker_a = spawn_worker(&cfg, cache.path(), "headless", "approve", "a", &[]); + + // ---- Phase 2: wait until the stub has received A's refresh request. + // This is an in-process await — no polling or timing. + // Once the stub is holding A's request, A owns the lock. + gate.wait_for_request().await; + + // ---- Phase 3: spawn B with SNAPSHOT_MARKER. B starts, reads the + // attempt sidecar (gen=0, absent), emits its snapshot + // event, and then blocks on the lock behind A. + let worker_b = spawn_worker( + &cfg, + cache.path(), + "headless", + "approve", + "b", + &[("AUTH_WORKER_SNAPSHOT_MARKER", snapshot_b.as_path())], + ); + + // ---- Phase 4: wait for B's snapshot marker. Proves B captured gen=0 + // before A can record gen=1; lock queueing is not required + // for the temporal-generation discriminator to hold. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // ---- Phase 5: release A. Stub returns invalid_grant; A records + // RefreshRejected with gen=1 and releases the lock. B + // acquires the lock, sees gen=1 > snap=0, and adopts. + gate.release(); + + let (out_a, out_b) = tokio::join!(worker_a.join(), worker_b.join()); + + // Both workers must report RefreshRejected. + assert_eq!( + out_a.result, "refresh_rejected", + "worker A gets RefreshRejected on a dead refresh" + ); + assert_eq!( + out_b.result, "refresh_rejected", + "worker B adopts RefreshRejected via the attempt sidecar" + ); + assert_eq!(out_a.launches, 0, "headless never opens a browser"); + assert_eq!(out_b.launches, 0, "headless never opens a browser"); + + // One refresh grant total: under the old protocol the second worker would + // re-run the dead refresh independently; the attempt record prevents that. + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "exactly one refresh grant across both headless processes" + ); +} + +#[tokio::test] +async fn test_crossprocess_userinitiated_waiter_adopts_predecessor_denial() { + // The adoption contract is *temporal*, not intent-based. A `UserInitiated` + // caller whose pre-queue snapshot is older than the current generation was + // already queued while the predecessor ran and MUST adopt its same-intent + // failure — exactly as the in-process `INFLIGHT` registry coalesces + // same-intent `UserInitiated` callers onto one leader within a process. + // + // When process A (UserInitiated) gets `Denied` and process B + // (UserInitiated) was queued *behind* it (B's snapshot predates A's write), + // B adopts A's denial without opening a second browser. The result: + // exactly one browser launch and zero code exchanges — one browser total + // across both processes. + // + // Note: this is different from a *later* explicit user retry, which + // arrives after A completes, snapshots the new generation, sees no advance, + // and naturally runs its own attempt. That behavior is proved by + // `test_crossprocess_post_failure_userinitiated_runs_own_attempt` below. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + let launched_a = cache.path().join("a.launched"); + let proceed_a = cache.path().join("a.proceed"); + + // Worker A holds the lock and keeps its browser open until we signal it, + // so B is certain to be queued behind A before A resolves. + let worker_a = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "a", + &[ + ("AUTH_WORKER_LAUNCHED_MARKER", launched_a.as_path()), + ("AUTH_WORKER_PROCEED_MARKER", proceed_a.as_path()), + ], + ); + + // Wait until A holds the lock and its browser is open. + wait_for_marker(&launched_a, "worker A browser launch").await; + + // Worker B (also UserInitiated, approve-scripted) queues behind A on the + // file lock. Even though B would succeed if it ran its own browser, it + // must adopt A's denial since it was queued while A held the lock. + // + // SNAPSHOT_MARKER is emitted by the tracing layer in B's process after B + // snapshots gen=0 and before it queues on the file lock — so observing it + // proves B captured generation 0 before A records generation 1. + let snapshot_b = cache.path().join("b.snapshot"); + let worker_b = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "approve", + "b", + &[("AUTH_WORKER_SNAPSHOT_MARKER", snapshot_b.as_path())], + ); + // Wait until B has snapshotted gen=0, then release A. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // Release A: it denies, writes the cooldown + attempt sidecars, releases lock. + std::fs::write(&proceed_a, b"go").unwrap(); + let out_a = worker_a.join().await; + assert_eq!(out_a.result, "denied", "worker A is denied"); + assert_eq!(out_a.launches, 1, "worker A opens one browser"); + + // Worker B adopts A's denial — it does not open a second browser even + // though it is UserInitiated. Under the old contract B would open its own + // browser and succeed; under the correct temporal contract it adopts. + let out_b = worker_b.join().await; + assert_eq!( + out_b.result, "denied", + "queued UserInitiated worker B adopts A's denial rather than re-running" + ); + assert_eq!( + out_b.launches, 0, + "worker B adopts the denial without opening a browser" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 0, + "no code exchange — B adopted A's Denied without reaching the token endpoint" + ); +} + +#[tokio::test] +async fn test_crossprocess_post_failure_userinitiated_runs_own_attempt() { + // A `UserInitiated` caller that arrives *after* a failure — not queued + // during it — snapshots the current (advanced) generation, sees no advance + // when it acquires the lock, and runs its own attempt. "Later explicit user + // retry bypasses" falls out of the temporal snapshot comparison without any + // special case. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Worker A (UserInitiated, deny-scripted) runs to completion first. No + // synchronization needed — we await it fully before constructing B. + let worker_a = spawn_worker(&cfg, cache.path(), "userinitiated", "deny", "a", &[]); + let out_a = worker_a.join().await; + assert_eq!(out_a.result, "denied", "worker A is denied"); + assert_eq!(out_a.launches, 1, "worker A opens one browser"); + + // Worker B arrives after A has fully completed and the attempt record is + // already written with the new generation. B snapshots the current + // (advanced) generation, acquires the lock, sees no further advance, and + // runs its own browser flow — it should succeed. + let worker_b = spawn_worker(&cfg, cache.path(), "userinitiated", "approve", "b", &[]); + let out_b = worker_b.join().await; + assert_eq!( + out_b.result, "ok", + "post-failure UserInitiated worker B runs its own flow and succeeds" + ); + assert_eq!( + out_b.launches, 1, + "worker B opens its own browser (not inherited from A)" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one code exchange (worker B's own approval)" + ); +} + +// ---- cross-process: adopter must NOT re-write the attempt generation ------- +// +// Proves that an adopting process B does not advance the attempt-sidecar +// generation, so a third process C — which arrives AFTER A's failure but sees +// no generation advance (B didn't re-write) — correctly runs its own attempt. +// +// Protocol ordering (deterministic via markers, no timing): +// 1. A (UserInitiated, deny-scripted) holds the lock mid-browser via +// LAUNCHED_MARKER + PROCEED_MARKER. +// 2. B (UserInitiated, deny-scripted) starts while A holds the lock. +// B emits SNAPSHOT_MARKER after snapshotting gen=0 and before queueing +// on the lock. Parent observes the marker, then signals A's proceed. +// 3. A: denial recorded, writes gen=1 to the attempt sidecar, releases lock. +// 4. B: acquires lock, sees gen=1 > snap=0, intent matches → adopts A's +// denial. With the fix B does NOT re-write the sidecar. With the mutation +// (restoring the deleted write_attempt at the adoption site) B writes +// gen=2. +// 5. After A and B finish: assert sidecar generation == 1. This is the +// discriminating assertion — it FAILS when the adoption-site re-write is +// restored (gen becomes 2 instead of 1). +// 6. C (UserInitiated, approve-scripted) starts fresh. C's snapshot == gen +// on disk (1 with fix, 2 with mutation). In both cases C sees no advance +// and runs its own browser flow. code_grants increments by 1 for C. +// +// This test is cache-free (no seed_cache / disk-token assertions) so it runs +// on Windows as well as Unix. + +#[tokio::test] +async fn test_crossprocess_adopter_does_not_advance_generation() { + let stub = spawn_stub(false).await; // deny does not hit any endpoint + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // ---- Phase 1: A holds the lock mid-browser ---------------------------- + let launched_a = cache.path().join("a.launched"); + let proceed_a = cache.path().join("a.proceed"); + + let worker_a = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "a", + &[ + ("AUTH_WORKER_LAUNCHED_MARKER", launched_a.as_path()), + ("AUTH_WORKER_PROCEED_MARKER", proceed_a.as_path()), + ], + ); + + // Wait until A holds the lock and its browser is open. + wait_for_marker(&launched_a, "worker A browser launch").await; + + // ---- Phase 2: B queues behind A, snapshot barrier --------------------- + // B is UserInitiated + deny-scripted, but B will adopt A's denial rather + // than opening its own browser (B was queued while A held the lock). + // SNAPSHOT_MARKER is emitted by the tracing layer in B's process after B + // snapshots gen=0 and before it queues on the lock — so observing it + // proves B captured generation 0 before A records generation 1. + let snapshot_b = cache.path().join("b.snapshot"); + let worker_b = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "b", + &[("AUTH_WORKER_SNAPSHOT_MARKER", snapshot_b.as_path())], + ); + + // Wait until B has snapshotted gen=0, then release A. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // ---- Phase 3: release A, let A fail and write gen=1 ------------------- + std::fs::write(&proceed_a, b"go").unwrap(); + let out_a = worker_a.join().await; + assert_eq!(out_a.result, "denied", "worker A is denied"); + assert_eq!(out_a.launches, 1, "worker A opens exactly one browser"); + + // ---- Phase 4: B adopts (does NOT re-write the sidecar) ---------------- + let out_b = worker_b.join().await; + assert_eq!( + out_b.result, "denied", + "worker B adopts A's denial — it does not open a second browser" + ); + assert_eq!( + out_b.launches, 0, + "worker B adopts without opening a browser" + ); + + // ---- Phase 5: discriminating generation check ------------------------- + // With the fix: sidecar gen == 1 (B did not re-write). + // Mutation check: restore the deleted `write_attempt` at the adoption site + // → B writes gen=2 → this assertion FAILS. + let sidecar = attempt_sidecar_path(&cfg, cache.path()); + let raw = std::fs::read(&sidecar).expect("attempt sidecar written by A"); + let record: serde_json::Value = serde_json::from_slice(&raw).expect("sidecar parses as JSON"); + assert_eq!( + record.get("generation").and_then(|v| v.as_u64()), + Some(1), + "adopter B must not advance the sidecar generation (gen must stay at 1, not 2)" + ); + + // ---- Phase 6: C runs its own attempt ---------------------------------- + // C arrives after A's failure. C's snapshot equals the on-disk generation + // (1 with fix, 2 with mutation). Either way C sees no advance and runs its + // own browser flow. But the sidecar check above already catches the + // mutation; C proves the end-to-end behaviour. + let worker_c = spawn_worker(&cfg, cache.path(), "userinitiated", "approve", "c", &[]); + let out_c = worker_c.join().await; + assert_eq!( + out_c.result, "ok", + "worker C (fresh arrival after A's failure) runs its own flow and succeeds" + ); + assert_eq!( + out_c.launches, 1, + "worker C opens its own browser — not inherited from A or B" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one code exchange — C's own approval (A was denied; B adopted without exchange)" + ); +} + +// ---- cross-process: a waiter with a different rejected must not inherit ---- +// +// Cross-process mirror of the in-process test above: process A carries +// `rejected = "X"` and the refresh stickily re-issues "X" → A's attempt +// records RefreshRejected with `rejected_digest = sha256("X")`. Process B +// waits on the lock with `rejected = "Y"` (different). When B acquires the +// lock and reads the attempt record, the digest mismatch causes B to run its +// own attempt rather than adopt A's failure — B's refresh gets "X", which is +// valid for B, so B succeeds. +// +// Ordering is established with deterministic markers and the in-process stub +// gate, not timing: +// 1. A spawns (headless, rejected="X"). The stub holds A's refresh response +// until the parent calls `gate.release()`. +// 2. Parent waits for `gate.wait_for_request()` — proves A has acquired the +// lock and is mid-refresh (the request arrived at the stub). +// 3. Parent spawns B (headless, rejected="Y", SNAPSHOT_MARKER=b.snapshot). +// 4. Parent waits for B's snapshot marker — proves B has snapshotted gen=0 +// and is queued on the lock. +// 5. Parent calls `gate.release()`: stub returns "X" to A. A finishes with +// RefreshRejected(digest(X)), writes sidecar gen=1, releases lock. +// 6. B acquires: gen=1 > snap=0, digest(Y) ≠ digest(X) → B runs its own +// refresh → gets "X" → Ok("X"). +// +// Mutation check (no digest gating): B adopts A's RefreshRejected → +// refresh_grants stays at 1 → `refresh_grants == 2` assertion FAILS. + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_waiter_with_different_rejected_does_not_adopt_leaders_failure() { + // Stub stickily returns "X" but holds each response until released. + let (stub, gate) = spawn_stub_with_held_refresh(HeldRefreshResponse::Sticky("X")).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Seed a token entry so both workers have a refresh token to exercise. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let result_a = cache.path().join("a.result.json"); + let result_b = cache.path().join("b.result.json"); + let snapshot_b = cache.path().join("b.snapshot"); + + // ---- Phase 1: spawn A. A will acquire the lock and immediately call the + // stub's refresh endpoint; the stub holds the response. + let mut cmd_a = tokio::process::Command::new(env!("CARGO_BIN_EXE_auth-worker")); + cmd_a + .env("AUTH_WORKER_DISCOVERY_URL", &cfg.discovery_url) + .env("AUTH_WORKER_CACHE_DIR", cache.path()) + .env("AUTH_WORKER_NAMESPACE", &cfg.cache_namespace) + .env("AUTH_WORKER_CLIENT_ID", &cfg.client_id) + .env("AUTH_WORKER_SCOPES", cfg.scopes.join(",")) + .env("AUTH_WORKER_INTENT", "headless") + .env("AUTH_WORKER_SCRIPT", "failopen") // headless never browses + .env("AUTH_WORKER_REJECTED", "X") + .env("AUTH_WORKER_RESULT", &result_a) + .kill_on_drop(true); + + let child_a = cmd_a.spawn().expect("spawn worker A"); + + // ---- Phase 2: wait until the stub has received A's refresh request. + // This is an in-process await — no polling or timing needed. + // Once the stub is holding A's request, A owns the lock. + gate.wait_for_request().await; + + // ---- Phase 3: spawn B with SNAPSHOT_MARKER. + let mut cmd_b = tokio::process::Command::new(env!("CARGO_BIN_EXE_auth-worker")); + cmd_b + .env("AUTH_WORKER_DISCOVERY_URL", &cfg.discovery_url) + .env("AUTH_WORKER_CACHE_DIR", cache.path()) + .env("AUTH_WORKER_NAMESPACE", &cfg.cache_namespace) + .env("AUTH_WORKER_CLIENT_ID", &cfg.client_id) + .env("AUTH_WORKER_SCOPES", cfg.scopes.join(",")) + .env("AUTH_WORKER_INTENT", "headless") + .env("AUTH_WORKER_SCRIPT", "failopen") + .env("AUTH_WORKER_REJECTED", "Y") + .env("AUTH_WORKER_RESULT", &result_b) + .env("AUTH_WORKER_SNAPSHOT_MARKER", &snapshot_b) + .kill_on_drop(true); + + let child_b = cmd_b.spawn().expect("spawn worker B"); + + // ---- Phase 4: wait for B's snapshot marker. The tracing layer in B fires + // this after B snapshots gen=0 and before it waits for the + // lock — proves B holds snap=0 and is queued behind A. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // ---- Phase 5: release A. Stub returns "X"; A records RefreshRejected + // with digest(X), advances gen to 1, releases the lock. + gate.release(); + + let worker_a = Worker { + child: child_a, + result_path: result_a, + }; + let worker_b = Worker { + child: child_b, + result_path: result_b, + }; + let (out_a, out_b) = tokio::join!(worker_a.join(), worker_b.join()); + + // A (rejected=X): refresh returns "X" → RefreshRejected. + // Sidecar: gen=1, result=refresh_rejected, rejected_digest=sha256("X"). + assert_eq!( + out_a.result, "refresh_rejected", + "worker A (rejected=X) must get RefreshRejected" + ); + // B (rejected=Y): gen=1 > snap=0, digest(Y) ≠ digest(X) → B runs its + // own refresh. B's refresh returns "X"; finish(rejected=Y, token=X) → Ok. + assert_eq!( + out_b.result, "ok", + "worker B (rejected=Y) must succeed after rerunning — not adopt A's RefreshRejected" + ); + // Mutation check (r8 shape, no digest gate): B adopts → refresh_grants + // stays 1. With the digest fix: B reruns → refresh_grants = 2. + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "both workers run their own refresh — digest mismatch prevented adoption" + ); +} + +// ---- P1-3 non-Unix read path disabled ----------------------------------- +// +// On non-Unix platforms (Windows) token files written by older builds with +// default ACLs should not be consumed by new builds. `read_private_cache` +// returns an error on non-Unix (and opportunistically removes the legacy +// file), so `read_cache` yields `None` and the source behaves as if no +// cached token exists — memory-only cache on non-Unix. +// +// This test uses a cfg-gated stub: on Unix it only exercises the Unix read +// path (as a sanity check); the Windows behavior is proved by the +// `#[cfg(not(unix))]` branch of `read_private_cache` and verified by the +// Windows CI build + manual testing on the Windows runner. The test is written +// to compile on all platforms and asserts the platform-appropriate invariant. + +#[tokio::test] +async fn test_non_unix_does_not_serve_legacy_on_disk_token() { + // Seed a token that would be served from disk on Unix (unexpired, valid). + let stub = spawn_stub(false).await; // fresh token on refresh/browser + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "legacy-windows-token", + "refresh_token": "legacy-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + #[cfg(unix)] + { + // On Unix the cache is read and served directly from disk — this is the + // expected behavior on a secured platform. + let token = src + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("Unix serves the seeded token from disk"); + assert_eq!(token, "legacy-windows-token", "Unix: disk token served"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 0, + "Unix: no refresh — the disk token was served directly" + ); + // The seeded file is still on disk (not removed on Unix). + assert!( + cache_file_path(&cfg, cache.path()).exists(), + "Unix: the cache file is preserved" + ); + } + + #[cfg(not(unix))] + { + // On non-Unix `read_private_cache` refuses to read the legacy file and + // attempts to remove it. Construction and bearer() behave as if no cache + // exists — the source falls through to a browser flow. + let token = src + .acquire_with_intent(AuthIntent::Auto, None) + .await + .expect("non-Unix: browser flow succeeds (no disk token served)"); + assert_ne!( + token, "legacy-windows-token", + "non-Unix: legacy token must not be served from disk" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "non-Unix: browser flow ran — disk token was not served" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 0, + "non-Unix: no refresh grant — the source went straight to the browser flow" + ); + // The legacy file should have been removed by read_private_cache. + assert!( + !cache_file_path(&cfg, cache.path()).exists(), + "non-Unix: legacy cache file is removed by read_private_cache" + ); + // No new token file was written (persist is a no-op on non-Unix). + // (The token is held in memory only.) + assert!( + !cache_file_path(&cfg, cache.path()).exists(), + "non-Unix: no new cache file created (memory-only)" + ); + } +} diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index fefd5a24c5d..9822243d5fe 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -80,19 +80,35 @@ async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc, ) -> (String, Arc>>) { + let captures: Arc>> = Arc::new(Mutex::new(Vec::new())); + let url = spawn_capturing_fake_llm_core(responses, captures.clone(), None).await; + (url, captures) +} + +/// Shared connection loop for the capturing fake LLM: reads each request, +/// records its JSON body into `captures`, and replies with the next canned +/// response. When `gate` is `Some`, the FIRST request's response is withheld +/// until the gate fires; when `None`, every response is served immediately. +async fn spawn_capturing_fake_llm_core( + responses: Vec, + captures: Arc>>, + gate: Option>>>>, +) -> String { 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))); - let captures: Arc>> = Arc::new(Mutex::new(Vec::new())); - let captures_clone = captures.clone(); tokio::spawn(async move { + let mut request_num = 0usize; loop { let (mut sock, _) = match listener.accept().await { Ok(p) => p, Err(_) => return, }; let queue = queue.clone(); - let captures = captures_clone.clone(); + let captures = captures.clone(); + let gate = gate.clone(); + request_num += 1; + let req_num = request_num; tokio::spawn(async move { // Read headers. let mut buf = Vec::new(); @@ -141,6 +157,15 @@ async fn spawn_capturing_fake_llm_with_statuses( captures.lock().await.push(parsed); } + // Hold the first request's response until the gate opens. + if req_num == 1 { + if let Some(gate) = &gate { + if let Some(rx) = gate.lock().await.take() { + let _ = rx.await; + } + } + } + // Send canned response. let response = queue.lock().await.pop_front().unwrap_or(CannedResponse { status: 500, @@ -164,6 +189,20 @@ async fn spawn_capturing_fake_llm_with_statuses( }); } }); + url +} + +/// A capturing fake LLM whose FIRST provider response is withheld until +/// `gate` fires. Later responses are served immediately. Used to make +/// round-boundary races deterministic: hold round 1 open until a client action +/// (e.g. a steer) is confirmed, so the second round observes it. Request bodies +/// are recorded into `captures` exactly as `spawn_capturing_fake_llm` does. +async fn spawn_gated_capturing_fake_llm( + responses: Vec, + captures: Arc>>, + gate: Arc>>>, +) -> (String, Arc>>) { + let url = spawn_capturing_fake_llm_core(responses, captures.clone(), Some(gate)).await; (url, captures) } @@ -774,14 +813,37 @@ async fn recv_active_run_id(h: &mut Harness) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn steer_folds_into_active_turn_without_cancelling() { + use tokio::sync::oneshot; + // A two-round turn (tool call → text). A steer sent once the run is live // must (a) be accepted with the matching runId, (b) NOT cancel the turn — // it still ends with end_turn — and (c) reach the provider as a user turn. - let (url, captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_steer", "fake__noop", json!({})), - openai_text("acknowledged the steer"), - ]) - .await; + // + // The steer is drained only at a round boundary (before the next provider + // request), so it must be enqueued before round 2 begins. Without + // synchronization a fast worker can complete round 1, drain an empty steer + // queue at the round-2 boundary, and dispatch round 2 before the steer is + // even sent — the steer then lands after the turn ends and never reaches + // the provider. To make this deterministic, the FIRST provider response is + // gated: it is withheld until the steer has been sent AND observed + // accepted, so round 1 cannot complete (and round 2 cannot start its drain) + // until the steer is already queued. + let (gate_tx, gate_rx) = oneshot::channel::<()>(); + let gate_rx = Arc::new(Mutex::new(Some(gate_rx))); + + let responses = vec![ + CannedResponse { + status: 200, + body: openai_tool_call("call_steer", "fake__noop", json!({})), + }, + CannedResponse { + status: 200, + body: openai_text("acknowledged the steer"), + }, + ]; + let captures: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (url, _) = spawn_gated_capturing_fake_llm(responses, captures.clone(), gate_rx).await; + let mut h = Harness::spawn(&url).await; let sid = init_session(&mut h).await; @@ -795,7 +857,8 @@ async fn steer_folds_into_active_turn_without_cancelling() { ) .await; - // Learn the run id, then steer into it before the turn finishes. + // Learn the run id (advertised before the gated round-1 request), then steer + // into the live turn while round 1 is still held. let run_id = recv_active_run_id(&mut h).await; let steer_text = "STEER-CANARY: also consider the edge case"; let s_id = h @@ -809,9 +872,12 @@ async fn steer_folds_into_active_turn_without_cancelling() { ) .await; - // Steer is accepted and echoes the run id it landed in. + // Steer is accepted and echoes the run id it landed in. Only after this + // confirmation do we release the gate, so the steer is guaranteed queued + // before round 2's boundary drains it. let mut steer_ok = false; let mut end_turn = false; + let mut gate = Some(gate_tx); for _ in 0..40 { let v = h.recv().await; if v["id"] == json!(s_id) { @@ -827,6 +893,11 @@ async fn steer_folds_into_active_turn_without_cancelling() { "steer reply carries a messageId" ); steer_ok = true; + // Steer accepted — release round 1 so the turn proceeds to round 2, + // whose boundary now drains the queued steer. + if let Some(tx) = gate.take() { + let _ = tx.send(()); + } } else if v["id"] == json!(p_id) { // The turn was NOT cancelled — it completed normally. assert_eq!(v["result"]["stopReason"], "end_turn"); diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index edee9090d84..fd5042a1167 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -2706,6 +2706,30 @@ async fn ordinary_400_stays_terminal_and_triggers_no_recovery() { /// part of the assertion. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn context_recovery_budget_exhaustion_surfaces_the_error() { + assert_context_recovery_budget_exhaustion(false).await; +} + +/// The same real provider/ACP scenario with stderr collection held until after +/// the stdout response. The old immediate snapshot cannot observe the budget. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_recovery_budget_exhaustion_waits_for_delayed_stderr() { + assert_context_recovery_budget_exhaustion(true).await; +} + +#[tokio::test] +#[should_panic(expected = "timed out waiting for stderr diagnostic")] +async fn stderr_diagnostic_wait_is_bounded_when_absent() { + let llm = spawn_capturing_llm(vec![]).await; + let h = Harness::spawn(&llm.url).await; + h.wait_for_stderr( + "diagnostic that is never emitted", + Duration::from_millis(20), + ) + .await; +} + +async fn assert_context_recovery_budget_exhaustion(delay_stderr: bool) { + let (release_stderr, stderr_gate) = tokio::sync::oneshot::channel(); // 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. @@ -2713,7 +2737,7 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { .map(|_| (400, openai_context_length_error())) .collect(); let llm = spawn_capturing_llm_with_status(responses).await; - let mut h = Harness::spawn_with_env( + let mut h = Harness::spawn_with_stderr_gate( &llm.url, &[ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), @@ -2723,6 +2747,7 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { ), ("BUZZ_AGENT_MAX_HANDOFFS", "0"), ], + delay_stderr.then_some(stderr_gate), ) .await; let sid = init_session(&mut h, json!([])).await; @@ -2751,8 +2776,27 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { // 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(); + // mechanism than its name claims. Pin the budget explicitly. Stdout is not + // a barrier for the independent stderr collector. + let stderr = { + let wait = h.wait_for_stderr("context recovery budget spent", Duration::from_secs(5)); + tokio::pin!(wait); + if delay_stderr { + assert!( + !h.stderr_text().contains("context recovery budget spent"), + "the old immediate snapshot must miss the held diagnostic" + ); + // Prove the actual wait stays pending before releasing the collector, + // without a sleep or depending on how quickly either task runs. + std::future::poll_fn(|cx| { + assert!(std::future::Future::poll(wait.as_mut(), cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + release_stderr.send(()).expect("release stderr collection"); + } + wait.await + }; assert!( stderr.contains("context recovery budget spent"), "the per-run recovery BUDGET must be what stops the loop here, not the prompt floor; \ @@ -2767,6 +2811,15 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { "expected all 3 recovery rungs to be attempted before giving up, saw {rungs} — \ stderr={stderr}" ); + assert!( + !stderr.contains("context recovery would shrink"), + "the prompt floor must not stop this fixture: {stderr}" + ); + assert_eq!( + llm.captured.lock().await.len(), + 4, + "expected the rejected completion plus exactly three failed summaries" + ); h.shutdown().await; } @@ -2816,7 +2869,9 @@ async fn small_history_context_400_refuses_rescue_at_the_prompt_floor() { r0.get("error").is_some(), "a context 400 with no shrinkable history must surface the error, got: {r0}" ); - let stderr = h.stderr_text(); + let stderr = h + .wait_for_stderr("context recovery would shrink", Duration::from_secs(5)) + .await; assert!( stderr.contains("below the") && stderr.contains("floor"), "the prompt-budget FLOOR must be what stops this, not the recovery budget; got: {stderr}" diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 9ae1d168590..6819fe23ca3 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -269,7 +269,7 @@ fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + /// The authenticated key-source contract: validated JWKS URI, refresh + /// interval, and hard deadline. Included in `derive_assertion_policy_id` + /// so that a change to the endpoint, refresh schedule, or hard-deadline + /// rule changes the policy ID and invalidates all prepared evidence. + jwks_source_contract: JwksSourceContract, id: AssertionPolicyId, } @@ -382,6 +392,10 @@ pub enum IssuerPolicyError { /// so subject classification could not be total and mutually exclusive. #[error("subject class contract is not exclusive")] NonExclusiveSubjectClass, + /// The [`JwksSourceContract`] was not valid — invalid URI, zero or + /// out-of-range timing, or `refresh_interval >= hard_deadline`. + #[error("invalid JWKS source contract")] + InvalidJwksSourceContract, } impl IssuerPolicy { @@ -393,10 +407,10 @@ impl IssuerPolicy { token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: JwksSourceContract, ) -> Result { // Identity-bearing strings are validated for bounds but never mutated: // exact `iss`/`aud`/`sub` bytes select policies and form the identity @@ -455,10 +469,10 @@ impl IssuerPolicy { &token_class, freshness, &algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + &jwks_source_contract, ); Ok(Self { @@ -467,10 +481,10 @@ impl IssuerPolicy { token_class, freshness, algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + jwks_source_contract, id, }) } @@ -500,11 +514,6 @@ impl IssuerPolicy { &self.algorithms } - /// Whether enrollment requires a `nostr_pubkey` claim equal to the actor. - pub const fn require_attested_key(&self) -> bool { - self.require_attested_key - } - /// The accepted clock skew, in seconds. pub const fn skew_seconds(&self) -> u64 { self.skew_seconds @@ -524,6 +533,11 @@ impl IssuerPolicy { pub const fn id(&self) -> AssertionPolicyId { self.id } + + /// The authenticated key-source contract for this policy's JWKS endpoint. + pub fn jwks_source_contract(&self) -> &JwksSourceContract { + &self.jwks_source_contract + } } /// A closed set of issuer policies keyed by exact `iss`. Selection preserves @@ -560,6 +574,12 @@ impl IssuerRegistry { pub fn is_empty(&self) -> bool { self.policies.is_empty() } + + /// Iteration order is deliberately unspecified; callers must not depend on + /// registration order. + pub fn all_policies(&self) -> impl Iterator { + self.policies.values() + } } /// Sort and deduplicate a set-valued list of strings into its canonical form. @@ -621,10 +641,10 @@ fn derive_assertion_policy_id( token_class: &TokenClass, freshness: FreshnessClass, algorithms: &[Algorithm], - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: &JwksSourceContract, ) -> AssertionPolicyId { let mut hasher = Sha256::new(); hasher.update(b"buzz:nip-fi:assertion-policy:v1\0"); @@ -676,10 +696,26 @@ fn derive_assertion_policy_id( &mut hasher, algorithms.iter().map(|a| algorithm_tag(*a).as_bytes()), ); - hasher.update([u8::from(require_attested_key)]); hasher.update(skew_seconds.to_be_bytes()); hasher.update(maximum_assertion_age_seconds.to_be_bytes()); hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes()); + // Authenticated key-source contract (NIP-FI.md, "Policy identity and + // snapshots"): URI selects the authenticated source; interval defines + // bounded refresh; hard deadline defines the accepted time rule. These are + // contract, not mutable state — key rotation (JWKS content change) leaves + // all three unchanged and must not move the ID. + hasher.update(b"jwks-source-contract\0"); + hash_field(&mut hasher, jwks_source_contract.jwks_uri().as_bytes()); + hasher.update( + jwks_source_contract + .refresh_interval_seconds() + .to_be_bytes(), + ); + hasher.update( + jwks_source_contract + .key_snapshot_hard_deadline_seconds() + .to_be_bytes(), + ); AssertionPolicyId(hasher.finalize().into()) } diff --git a/crates/buzz-auth/src/nip_fi/discovery.rs b/crates/buzz-auth/src/nip_fi/discovery.rs new file mode 100644 index 00000000000..8d1b1500b12 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/discovery.rs @@ -0,0 +1,66 @@ +//! NIP-11 federated-identity discovery output. +//! +//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity` +//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay +//! information document. +//! +//! ## Privacy invariants +//! +//! The discovery object MUST NOT contain: enrollment mode, TOFU posture, +//! issuer URLs, audiences, claim names, tenant IDs, or deployment-local +//! identifiers. For a fixed set of claimed profiles the complete output is +//! byte-identical across every enrollment policy and lifecycle state. +//! [FI-TRACE-DISCOVERY-PRIVATE] +//! +//! ## Offline-jwt residual bound +//! +//! `maximum_residual_upstream_revocation_seconds` is `null` for `offline-jwt` +//! deployments. An offline-jwt deployment MUST NOT advertise a finite value +//! here (NIP-FI.md:259-266). + +use serde::{Deserialize, Serialize}; + +/// The `assertion_freshness` sub-object in the `federated_identity` discovery +/// document. Describes the claimed freshness posture without exposing any +/// issuer or deployment-private state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssertionFreshnessDiscovery { + /// The wire string identifying the freshness class. + pub class: FreshnessClassDiscovery, + /// `null` for `offline-jwt`; advertising a finite bound here requires a + /// live status witness that is not yet implemented. + pub maximum_residual_upstream_revocation_seconds: Option, +} + +/// The freshness class as a stable NIP-FI wire string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FreshnessClassDiscovery { + /// No revocation bound is claimed; JWKS snapshot validation only. + OfflineJwt, +} + +/// The `federated_identity` NIP-11 discovery object. Fields never expose +/// enrollment mode, issuer, audience, or private state. +/// [FI-TRACE-DISCOVERY-PRIVATE] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FederatedIdentityDiscovery { + /// Fixed value `"client-attached"` for the core NIP-FI transport mode. + pub core: String, + /// The freshness contract claimed by this deployment. + pub assertion_freshness: AssertionFreshnessDiscovery, +} + +impl FederatedIdentityDiscovery { + /// The only supported posture: claims no residual revocation bound, which + /// is the honest description of JWKS-only assertion verification. + pub fn offline_jwt() -> Self { + Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::OfflineJwt, + maximum_residual_upstream_revocation_seconds: None, + }, + } + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs new file mode 100644 index 00000000000..618ee6b0696 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -0,0 +1,738 @@ +//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`] +//! implementation for federated-assertion verification. +//! +//! ## Design invariants +//! +//! - **Issuer binding is sealed.** [`ProductionJwksSource`] builds each +//! [`AssertionKeySet`] using the crate-private constructor and stores it +//! keyed by the exact `iss` it authenticates. A caller cannot relabel one +//! issuer's JWKS as another's — the cross-issuer bypass is closed at both +//! the request seam (the verifier re-checks `iss`) and here. +//! +//! - **No stale-key fallback.** On fetch error the source returns the current +//! snapshot if it is within its hard deadline, or `None`. It never serves +//! an expired snapshot. [FI-TRACE-JWKS-REMOVE] +//! +//! - **Bounded resource acquisition.** HTTP response streaming stops at +//! [`MAX_JWKS_RESPONSE_BYTES`] + 1 byte before any allocation for parsing. +//! Key count is bounded by [`super::config::MAX_JWKS_KEYS`] inside +//! [`AssertionKeySet::new`]. +//! +//! - **Coalesced refresh.** A single in-flight refresh per issuer prevents +//! thundering-herd. Concurrent callers observe the snapshot just after the +//! racing refresh commits. +//! +//! - **No secrets or key material in errors or logs.** [`JwksFetchError`] +//! carries only non-sensitive diagnostic codes. + +use super::config::MAX_JWKS_KEYS; +use super::verifier::{AssertionKeySet, IssuerKeySource}; +use buzz_core::network::is_not_global_unicast; +use chrono::{DateTime, Duration, Utc}; +use futures_util::StreamExt as _; +use jsonwebtoken::jwk::JwkSet; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; +use url::Url; + +/// Maximum HTTP response body for a JWKS endpoint. Streaming stops at this +/// limit before any deserialization, preventing OOM from a malicious server. +pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB + +/// Hard upper bound on JWKS timing fields. Values above this are rejected at +/// config construction to prevent `u64`→`i64` conversion overflow and Chrono +/// range panics when computing snapshot deadlines. +pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year + +/// Hard deadline for the complete JWKS fetch: hostname resolution, connect, +/// headers, and body streaming combined. Applied via `tokio::time::timeout` +/// so a stalled resolver cannot keep `fetch_jwks` pending indefinitely. +pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10; + +/// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials, +/// no fragment, and the host (if a bare IP) is not private/reserved. +/// Hostname targets are resolved and checked at every fetch in `fetch_jwks` +/// to prevent DNS rebinding — this check catches the most common +/// misconfiguration at construction time. +pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + if parsed.scheme() != "https" { + return Err(JwksFetchError::InvalidUri); + } + // Credentials in the URI are never legitimate for a public JWKS endpoint + // and would be forwarded to the server, leaking material in logs. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Fragments are client-side only; their presence indicates a misconfigured URI. + if parsed.fragment().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Reject bare private/reserved IP targets at construction time. + if let Some(url::Host::Ipv4(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V4(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + if let Some(url::Host::Ipv6(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V6(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(()) +} + +/// The authenticated key-source contract owned by one [`IssuerPolicy`]. +/// +/// Encodes the three deployment-configured fields whose change alters which +/// keys the runtime trusts and how long it trusts them: +/// +/// - `jwks_uri` — selects the authenticated key source; a different endpoint +/// may serve different keys even for the same issuer. +/// - `refresh_interval_seconds` — defines bounded refresh behavior; a longer +/// interval allows stale keys to persist longer. +/// - `key_snapshot_hard_deadline_seconds` — defines the source's accepted +/// time rule; the per-snapshot absolute deadline that flows into every +/// sealed [`VerifiedAssertion`][crate::nip_fi::VerifiedAssertion]'s +/// revalidation dependencies derives from this. +/// +/// This type is the single source of truth for these fields. `IssuerJwksConfig` +/// is built from it (pairing it with the bare issuer string) rather than +/// independently restating the same values. Having both types carry independent +/// copies of these fields would let them drift silently; startup validation +/// detects any mismatch that a compatibility path temporarily introduces. +/// +/// All three fields are validated at construction — an invalid value is caught +/// at configuration time, not at first token verification. +/// +/// ## Why these fields are contract, not mutable state +/// +/// Per the settled NIP-FI spec ("Policy identity and snapshots"): +/// `assertion_policy_id` covers "authenticated key/status-source contracts" +/// and "time rules". Key additions/removals (JWKS rotation) and per-snapshot +/// deadlines remain *revalidation dependencies* — they change per-token state +/// without changing the contract. These three fields define what the contract +/// *is*; JWKS content is what the contract currently *says*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JwksSourceContract { + /// Validated JWKS endpoint URI normalized to its canonical `Url` serialization. + /// `Url::to_string()` lowercases the scheme and host, removes the default + /// HTTPS port, and resolves dot-segments — so equivalent URI spellings hash + /// identically. Validated at construction; only stored after parse succeeds. + jwks_uri: String, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly less than + /// `key_snapshot_hard_deadline_seconds`. + refresh_interval_seconds: u64, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly greater than + /// `refresh_interval_seconds`. + key_snapshot_hard_deadline_seconds: u64, +} + +impl JwksSourceContract { + /// Validate and seal the three JWKS source fields. + /// + /// Rejects: + /// - `jwks_uri` that fails [`validate_jwks_uri`] + /// - zero `refresh_interval_seconds` or `key_snapshot_hard_deadline_seconds` + /// - `refresh_interval_seconds >= key_snapshot_hard_deadline_seconds` (the + /// hard deadline must be strictly greater so a snapshot is fresh for at + /// least one refresh cycle) + /// - either timing field exceeding [`MAX_JWKS_TIMING_SECONDS`] + pub fn new( + jwks_uri: String, + refresh_interval_seconds: u64, + key_snapshot_hard_deadline_seconds: u64, + ) -> Option { + if refresh_interval_seconds == 0 + || key_snapshot_hard_deadline_seconds == 0 + || key_snapshot_hard_deadline_seconds <= refresh_interval_seconds + || refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS + { + return None; + } + // Parse once, reject via validate_jwks_uri's rule-set, then store the + // canonical serialization produced by `Url::to_string()`. The `url` + // crate lowercases scheme and host, removes the default HTTPS port, + // and resolves dot-segments — guaranteeing that equivalent URI spellings + // (e.g. uppercase host, explicit `:443`, `.///../`) produce an identical + // stored string and therefore an identical `AssertionPolicyId` hash. + let canonical_uri = match Url::parse(&jwks_uri) { + Ok(parsed) => parsed.to_string(), + Err(_) => return None, + }; + // Re-validate on the canonical form so that any normalisation that + // would introduce a forbidden form (e.g. port stripping that leaves + // a bare-IP host) is caught here rather than silently stored. + if validate_jwks_uri(&canonical_uri).is_err() { + return None; + } + Some(Self { + jwks_uri: canonical_uri, + refresh_interval_seconds, + key_snapshot_hard_deadline_seconds, + }) + } + + /// The validated JWKS endpoint URI. + pub fn jwks_uri(&self) -> &str { + &self.jwks_uri + } + + /// Seconds between successive JWKS refreshes. + pub const fn refresh_interval_seconds(&self) -> u64 { + self.refresh_interval_seconds + } + + /// Hard upper bound (from fetch time) on how long a snapshot may be served. + pub const fn key_snapshot_hard_deadline_seconds(&self) -> u64 { + self.key_snapshot_hard_deadline_seconds + } +} + +/// Resolve `host:port` to IP addresses and reject if any are private/reserved. +/// +/// Returns the first safe address for DNS pinning. Blocks on the OS resolver +/// via `spawn_blocking` to avoid blocking the async runtime. +/// +/// Uses the `(host, port)` tuple form of `ToSocketAddrs` — not +/// `format!("{host}:{port}")` — so IPv6 literal hosts (returned without +/// brackets by `Url::host_str()`) are handled correctly without socket-address +/// ambiguity. +/// +/// Rejecting *any* resolved address (not just the first) closes split-horizon +/// DNS attacks: if an attacker can cause one DNS record to resolve to a private +/// address, the entire request is blocked even when other records are public. +pub(crate) async fn resolve_and_check_ssrf( + host: &str, + port: u16, +) -> Result { + // Fast path: if the host is already a parsed IP literal, skip the resolver. + if let Ok(ip) = host.parse::() { + if is_not_global_unicast(&ip) { + return Err(JwksFetchError::InvalidUri); + } + return Ok(ip); + } + + // Hostname path: use the tuple form to avoid IPv6-bracket ambiguity. + let host_owned = host.to_owned(); + let addrs: Vec = tokio::task::spawn_blocking(move || { + use std::net::ToSocketAddrs; + (host_owned.as_str(), port) + .to_socket_addrs() + .map(|iter| iter.map(|sa| sa.ip()).collect::>()) + }) + .await + .map_err(|_| JwksFetchError::NetworkError)? + .map_err(|_| JwksFetchError::NetworkError)?; + + if addrs.is_empty() { + return Err(JwksFetchError::NetworkError); + } + for ip in &addrs { + if is_not_global_unicast(ip) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(addrs[0]) +} + +#[derive(Clone)] +struct CachedSnapshot { + key_set: AssertionKeySet, + fetched_at: DateTime, + hard_deadline: DateTime, + /// SHA-256 of the raw JWKS bytes. Suppresses generation advances when the + /// document is unchanged between refreshes. [FI-TRACE-JWKS-ADD/REMOVE] + content_digest: [u8; 32], +} + +struct IssuerState { + snapshot: Option, + /// Advances only when `content_digest` changes; never wraps (saturating). + generation_counter: u64, + /// Owned permit for in-flight refresh. Held across the complete fetch + + /// state commit; dropped automatically if the caller future is cancelled. + /// `try_lock_owned()` succeeds iff no refresh is in progress. + refresh_permit: Arc>, +} + +impl IssuerState { + fn new() -> Self { + Self { + snapshot: None, + generation_counter: 0, + refresh_permit: Arc::new(tokio::sync::Mutex::new(())), + } + } +} + +/// Per-issuer JWKS endpoint configuration. Pairs the exact `iss` value with +/// the policy-owned [`JwksSourceContract`] that was already validated at +/// [`IssuerPolicy`][super::config::IssuerPolicy] construction. +/// +/// `IssuerJwksConfig` is the single combination of issuer string and contract +/// that `ProductionJwksSource` operates on. Because the contract fields are +/// sealed inside [`JwksSourceContract`] and validated there, this type carries +/// no independent copies of those values — startup validation enforces that the +/// contract embedded here matches the one carried by the corresponding policy. +#[derive(Debug, Clone)] +pub struct IssuerJwksConfig { + /// The exact `iss` value this config authenticates. Must match the + /// corresponding [`IssuerPolicy`][super::config::IssuerPolicy] exactly. + pub issuer: String, + /// The validated key-source contract owned by the matching policy. Carries + /// the JWKS URI, refresh interval, and hard deadline — validated at + /// [`JwksSourceContract::new`], not re-validated here. + pub contract: JwksSourceContract, +} + +/// Reason a JWKS fetch or parse operation failed. No key material, issuer +/// URLs, or raw response content appear in these variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum JwksFetchError { + /// Non-HTTPS scheme, embedded credentials, fragment, bare + /// private/reserved IP host, or DNS resolved to a private/reserved address. + #[error("JWKS URI failed safety validation")] + InvalidUri, + /// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`]. + #[error("JWKS response exceeded size limit")] + ResponseTooLarge, + /// Network failure, TLS error, request timeout, or non-2xx status. + #[error("JWKS HTTP request failed")] + NetworkError, + /// Response body was not parseable as a JWK Set. + #[error("JWKS response was not parseable")] + ParseError, + /// Parsed key set was empty or exceeded [`super::config::MAX_JWKS_KEYS`]. + #[error("JWKS key set bounds violation")] + KeyCountBoundsViolation, +} + +/// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth` +/// may implement it — external types cannot name the private supertrait. +/// +/// Implementations MUST: +/// - validate the URI (scheme, credentials, fragment, bare private-IP host) +/// before any I/O; +/// - resolve hostname targets and reject any private/reserved resolved address; +/// - deny redirects (3xx responses rejected as `NetworkError`); +/// - enforce a finite per-fetch deadline covering resolution, connect, headers, +/// and body streaming — the entire operation must be bounded; +/// - enforce [`MAX_JWKS_RESPONSE_BYTES`] via incremental streaming; +/// - reject non-2xx responses. +pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { + /// Fetch and return the raw JSON body from the given JWKS URI. + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a; +} + +/// Production [`JwksFetcher`] backed by `reqwest`. Each call to `fetch_jwks` +/// builds a dedicated pinned client — no shared connection state between fetches. +/// +/// Per-fetch boundary enforcement: +/// - hostname DNS is resolved and every address checked against +/// `buzz_core::network::is_not_global_unicast` before the request is sent; +/// - the request is pinned to the validated address to prevent DNS rebinding +/// TOCTOU (the OS resolver is called once per fetch, not once per URL); +/// - the complete operation (resolution, connect, headers, body streaming) is +/// bounded by [`JWKS_REQUEST_TIMEOUT_SECS`] via `tokio::time::timeout`; +/// - 3xx responses are rejected as `NetworkError` — redirects are never followed; +/// - the body is streamed incrementally and stopped at +/// [`MAX_JWKS_RESPONSE_BYTES`] + 1. +#[derive(Clone, Debug)] +pub struct HttpJwksFetcher; + +impl HttpJwksFetcher { + /// Builds a new fetcher. Security invariants are enforced per-request in + /// `fetch_jwks` — each call constructs a dedicated pinned client. + pub fn new() -> Self { + Self + } +} + +impl Default for HttpJwksFetcher { + fn default() -> Self { + Self::new() + } +} + +impl super::verifier::sealed::Sealed for HttpJwksFetcher {} + +impl JwksFetcher for HttpJwksFetcher { + async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { + with_deadline( + fetch_jwks_inner(uri), + std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS), + ) + .await + } +} + +/// Bound `fut` with a hard `tokio::time::timeout`. Elapsed maps to +/// `NetworkError`. Production passes `fetch_jwks_inner(uri)`; tests pass +/// `std::future::pending()` to verify the seam deterministically. +async fn with_deadline(fut: F, timeout: std::time::Duration) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(timeout, fut) + .await + .map_err(|_| JwksFetchError::NetworkError)? +} + +/// Extract the bare host string and port from a validated JWKS URI. +/// +/// The host is extracted via the typed `Url::host()` accessor, **not** +/// `host_str()`. `host_str()` returns IPv6 literals with brackets (e.g. +/// `[2606:4700::1]`), which breaks `IpAddr::parse`: brackets are not valid, +/// so the fast path in `resolve_and_check_ssrf` would fail and fall through +/// to the DNS path, which may attempt to resolve `[2606:4700::1]` as a +/// hostname instead of an IP literal. +/// +/// The extracted bare host string is also the correct input form for +/// `reqwest::ClientBuilder::resolve(host, addr)`, whose key must match the +/// URL authority form (bare, without brackets for IPv6). Whether the +/// connector-level pin behaves as expected under mutation is a runtime +/// boundary concern; this function's contract is that it produces the bare +/// form required as input. +/// +/// This function is `pub(crate)` so tests can assert the extracted host string +/// directly and confirm the mutation (restoring `host_str()`) turns the +/// equivalence oracle red without making a live network request. +/// +/// ## Mutation oracle +/// Restoring `Some(url::Host::Ipv6(addr)) => format!("[{}]", addr)` (the +/// `host_str()` form) causes the IPv6 host extraction test to fail: the +/// returned string carries brackets, `IpAddr::parse` rejects it, and the +/// extracted host no longer matches the bare URL authority form. +pub(crate) fn extract_url_host_and_port(uri: &str) -> Result<(String, u16), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + let host = match parsed.host() { + Some(url::Host::Ipv4(addr)) => addr.to_string(), + // MUST use the typed accessor — `host_str()` returns `[2606:4700::1]` + // (with brackets) for IPv6 literals, which breaks IpAddr::parse. + Some(url::Host::Ipv6(addr)) => addr.to_string(), + Some(url::Host::Domain(d)) => d.to_owned(), + None => return Err(JwksFetchError::InvalidUri), + }; + let port = parsed.port_or_known_default().unwrap_or(443); + Ok((host, port)) +} + +/// Inner fetch logic. Called only by `HttpJwksFetcher::fetch_jwks` via `with_deadline`. +async fn fetch_jwks_inner(uri: &str) -> Result { + // Full URI validation first — scheme, credentials, fragment, bare + // private-IP host. This enforces the JwksFetcher contract for direct + // callers of HttpJwksFetcher regardless of whether ProductionJwksSource + // pre-validated the URI. + validate_jwks_uri(uri)?; + + let (host, port) = extract_url_host_and_port(uri)?; + + // Resolve and check every IP before sending. Pins DNS to the validated + // address to prevent rebinding TOCTOU between check and connect. + let safe_ip = resolve_and_check_ssrf(&host, port).await?; + + // Build a per-request client that: + // - denies redirects (a 3xx to an internal host bypasses the URI check); + // - has no system proxy (proxy would resolve the original hostname itself); + // - pins this request to the validated IP. + let pinned_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve(&host, std::net::SocketAddr::new(safe_ip, port)) + .build() + .map_err(|_| JwksFetchError::NetworkError)?; + + let response = pinned_client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Reject non-2xx. A 3xx here means our no-redirect policy was somehow + // bypassed — treat as a network error. + if !response.status().is_success() { + return Err(JwksFetchError::NetworkError); + } + + // Early-exit on Content-Length before streaming. A lying or absent + // Content-Length is caught by the incremental counter below. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + } + + // Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we + // never buffer more than the limit before rejecting. + let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024)); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?; + if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + + String::from_utf8(body).map_err(|_| JwksFetchError::ParseError) +} + +fn parse_and_bound_jwks(body: &str) -> Result { + let key_set: JwkSet = serde_json::from_str(body).map_err(|_| JwksFetchError::ParseError)?; + if key_set.keys.is_empty() || key_set.keys.len() > MAX_JWKS_KEYS { + return Err(JwksFetchError::KeyCountBoundsViolation); + } + Ok(key_set) +} + +/// Multi-issuer JWKS cache that performs bounded periodic refresh and never +/// serves snapshots past their hard deadline. +/// +/// Must be constructed at startup after +/// [`super::startup::validate_nip_fi_config`] passes. Shared across async +/// tasks via the inner `Arc>`. +/// +/// ## Security +/// +/// - Each issuer's JWKS is stored under its exact `iss` — no relabelling. +/// - Expired snapshots are purged on access; no stale-key fallback. +/// - Errors are logged with a stable code; no key material appears in logs. +pub struct ProductionJwksSource { + configs: HashMap, + states: Arc>>>, + fetcher: Arc, + /// Clock used for `hard_deadline` computation and expiry checks. Always + /// `Arc::new(Utc::now)` in production; tests supply a controlled clock. + now_fn: Arc DateTime + Send + Sync>, +} + +impl ProductionJwksSource { + /// Returns `None` when `configs` is empty or any two configs share the + /// same `issuer` (duplicate issuers make trust configuration ambiguous). + /// + /// Contract fields (`jwks_uri`, `refresh_interval_seconds`, + /// `key_snapshot_hard_deadline_seconds`) are pre-validated inside the + /// embedded [`JwksSourceContract`] — no re-validation is performed here. + pub fn new(configs: Vec, fetcher: F) -> Option { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + if config_map.contains_key(&c.issuer) { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + now_fn: Arc::new(Utc::now), + }) + } + + /// **Test-only.** Construct with an injectable clock so tests can advance + /// `now` past snapshot hard deadlines without wall-clock sleep. + #[cfg(test)] + pub(crate) fn new_with_clock( + configs: Vec, + fetcher: F, + now_fn: Arc DateTime + Send + Sync>, + ) -> Option { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + if config_map.contains_key(&c.issuer) { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + now_fn, + }) + } + + async fn fetch_fresh( + &self, + issuer: &str, + prev_digest: Option<[u8; 32]>, + prev_generation: u64, + ) -> Option<(CachedSnapshot, u64)> { + let config = self.configs.get(issuer)?; + let body = match self.fetcher.fetch_jwks(config.contract.jwks_uri()).await { + Ok(b) => b, + Err(err) => { + warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live"); + return None; + } + }; + + let jwks = match parse_and_bound_jwks(&body) { + Ok(k) => k, + Err(err) => { + warn!(error = %err, "nip-fi jwks parse failed; will use cached snapshot if live"); + return None; + } + }; + + let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into(); + + // Advance only when the document changed so key-rotation events are + // visible [FI-TRACE-JWKS-ADD/REMOVE] while identical refetches are + // stable. Saturating add prevents wrap on the (unreachable) u64 ceiling. + let generation = if Some(content_digest) == prev_digest { + prev_generation + } else { + prev_generation.saturating_add(1).max(1) + }; + + let now = (self.now_fn)(); + // MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is + // always safe for values that passed the bounds check in JwksSourceContract::new(). + let deadline_secs = i64::try_from(config.contract.key_snapshot_hard_deadline_seconds()) + .unwrap_or(i64::MAX / 2); + let hard_deadline = now + + Duration::try_seconds(deadline_secs) + .unwrap_or_else(|| Duration::seconds(i64::MAX / 2)); + + let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?; + + Some(( + CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + content_digest, + }, + generation, + )) + } + + /// Returns the cached snapshot for `issuer`, refreshing inline if stale. + /// Returns `None` when no live snapshot is available and the fetch fails. + /// + /// Coalesces concurrent callers: a second call while a refresh is in + /// flight returns the current snapshot immediately rather than starting a + /// second fetch. The refresh permit is an RAII guard — if this future is + /// cancelled while DNS, HTTP, or streaming is pending, the guard drops and + /// the permit is released, so the next caller can start a new fetch. + pub async fn get_snapshot(&self, issuer: &str) -> Option { + let states = self.states.read().await; + let state_mutex = states.get(issuer)?; + let mut state = state_mutex.lock().await; + + let now = (self.now_fn)(); + let config = self.configs.get(issuer)?; + + if let Some(ref cached) = state.snapshot { + if now >= cached.hard_deadline { + state.snapshot = None; + } + } + + let needs_refresh = match state.snapshot { + None => true, + Some(ref cached) => { + let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; + age_secs >= config.contract.refresh_interval_seconds() + } + }; + + if !needs_refresh { + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + // Try to acquire the per-issuer refresh permit. Failure means another + // caller is already fetching; return the current snapshot rather than + // starting a second fetch. + let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() { + Ok(g) => g, + Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()), + }; + + let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); + let prev_generation = state.generation_counter; + drop(state); + drop(states); + + let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; + + // Re-acquire state to commit and release the permit atomically. + let states = self.states.read().await; + if let Some(state_mutex) = states.get(issuer) { + let mut st = state_mutex.lock().await; + if let Some((ref cached, new_generation)) = fresh { + st.generation_counter = new_generation; + st.snapshot = Some(cached.clone()); + } + // Drop the permit only after the state commit is visible. + drop(permit); + let now2 = (self.now_fn)(); + return st + .snapshot + .as_ref() + .filter(|c| now2 < c.hard_deadline) + .map(|c| c.key_set.clone()); + } + + drop(permit); + None + } +} + +impl super::verifier::sealed::Sealed for ProductionJwksSource {} + +impl IssuerKeySource for ProductionJwksSource { + /// Called per-request by the verifier after the cache has been warmed via + /// [`get_snapshot`][Self::get_snapshot]. + /// + /// Uses `try_read`/`try_lock` — safe to call from any async context. + /// Fails closed (returns `None`) when the lock is momentarily held by an + /// in-flight refresh, rather than blocking or panicking. [FI-INV-14] + fn key_set(&self, issuer: &str) -> Option { + let states = self.states.try_read().ok()?; + let state_mutex = states.get(issuer)?; + let state = state_mutex.try_lock().ok()?; + let now = (self.now_fn)(); + state + .snapshot + .as_ref() + .filter(|c| now < c.hard_deadline) + .map(|c| c.key_set.clone()) + } +} + +impl std::fmt::Debug for ProductionJwksSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // No issuer URIs or key material in debug output. + write!( + f, + "ProductionJwksSource([REDACTED; {} issuers])", + self.configs.len() + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs new file mode 100644 index 00000000000..df75e70da06 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -0,0 +1,1621 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +struct FakeJwksFetcher { + body: Result, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for FakeJwksFetcher {} + +impl JwksFetcher for FakeJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self.body.clone(); + self.call_count.fetch_add(1, Ordering::SeqCst); + async move { result } + } +} + +fn minimal_jwks_json(kid: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# + ) +} + +fn make_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid test contract"), + } +} + +fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> Option { + JwksSourceContract::new(jwks_uri.to_owned(), 300, 3600).map(|contract| IssuerJwksConfig { + issuer: issuer.to_owned(), + contract, + }) +} + +#[tokio::test] +async fn get_snapshot_returns_sealed_key_set_on_success() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + let ks = source.get_snapshot(issuer).await.unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +#[tokio::test] +async fn get_snapshot_returns_none_for_unknown_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = + ProductionJwksSource::new(vec![make_config("https://id.example")], fetcher).unwrap(); + + assert!(source.get_snapshot("https://other.example").await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_network_error_with_no_cache() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::NetworkError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_oversized_response() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ResponseTooLarge), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_parse_error() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ParseError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn parse_and_bound_rejects_empty_key_set() { + let err = parse_and_bound_jwks(r#"{"keys":[]}"#).unwrap_err(); + assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); +} + +#[tokio::test] +async fn parse_and_bound_rejects_oversized_key_set() { + let keys: Vec = (0..=MAX_JWKS_KEYS) + .map(|i| format!( + r#"{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","kid":"k{i}"}}"# + )) + .collect(); + let body = format!(r#"{{"keys":[{}]}}"#, keys.join(",")); + assert_eq!( + parse_and_bound_jwks(&body).unwrap_err(), + JwksFetchError::KeyCountBoundsViolation + ); +} + +#[tokio::test] +async fn new_rejects_empty_configs() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new(vec![], fetcher).is_none()); +} + +/// Timing validation is now performed by `JwksSourceContract::new`. These +/// tests verify the contract constructor rejects bad timing, since an invalid +/// contract prevents building an `IssuerJwksConfig` entirely. +#[test] +fn contract_rejects_refresh_ge_hard_deadline() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 3600, + 3600, + ) + .is_none()); +} + +#[test] +fn contract_rejects_zero_refresh_interval() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 0, + 3600, + ) + .is_none()); +} + +#[test] +fn contract_rejects_timing_above_maximum() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + MAX_JWKS_TIMING_SECONDS + 1, + MAX_JWKS_TIMING_SECONDS + 2, + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_duplicate_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let config_a = make_config(issuer); + let config_b = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks-alt.json".to_owned(), + 600, + 7200, + ) + .unwrap(), + }; + assert!(ProductionJwksSource::new(vec![config_a, config_b], fetcher).is_none()); +} + +/// URI validation is now performed by `JwksSourceContract::new`; an invalid +/// URI makes the contract `None` and prevents an `IssuerJwksConfig` from being +/// built at all. The tests below verify that `JwksSourceContract::new` rejects +/// the same invalid URIs that `ProductionJwksSource::new` previously checked. +#[test] +fn contract_rejects_non_https_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "http://id.example/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_loopback_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://127.0.0.1/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_private_ip_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://10.0.0.1/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_jwks_uri_with_credentials() { + assert!(make_config_with_uri( + "https://id.example", + "https://user:pass@id.example/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_jwks_uri_with_fragment() { + assert!(make_config_with_uri( + "https://id.example", + "https://id.example/.well-known/jwks.json#keys" + ) + .is_none()); +} + +/// `key_set()` fails closed (returns `None`) before any snapshot is warmed via +/// `get_snapshot` — the synchronous path never fetches. +#[tokio::test] +async fn sync_key_set_returns_none_before_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!(source.key_set(issuer).is_none()); +} + +#[tokio::test] +async fn sync_key_set_returns_snapshot_after_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + source.get_snapshot(issuer).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let ks = source.key_set(issuer).unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +/// Identical document fetched twice must not advance the generation counter +/// — stable generation for unchanged JWKS prevents spurious revalidation. +#[tokio::test] +async fn generation_stable_for_identical_document() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), + }; + let source = ProductionJwksSource::new(vec![config], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert_eq!(gen1, gen2); +} + +/// Changed document must advance the generation so key-rotation events are +/// visible [FI-TRACE-JWKS-ADD/REMOVE]. +#[tokio::test] +async fn generation_advances_for_changed_document() { + let issuer = "https://id.example"; + + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(minimal_jwks_json("k2")), + Ok(minimal_jwks_json("k1")), + ])); + + struct MultiBodyFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for MultiBodyFetcher {} + impl JwksFetcher for MultiBodyFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), + }; + let source = ProductionJwksSource::new(vec![config], MultiBodyFetcher { bodies }).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert!(gen2 > gen1, "gen1={gen1}, gen2={gen2}"); +} + +#[test] +fn validate_uri_accepts_valid_https() { + assert!(validate_jwks_uri("https://id.example/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_accepts_public_ipv6() { + assert!(validate_jwks_uri("https://[2606:4700::1]/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_http() { + assert_eq!( + validate_jwks_uri("http://id.example/.well-known/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_loopback_ip() { + assert_eq!( + validate_jwks_uri("https://127.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_private_ip() { + assert_eq!( + validate_jwks_uri("https://192.168.1.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_link_local_ip() { + assert_eq!( + validate_jwks_uri("https://169.254.169.254/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_1() { + // 192.0.2.0/24 — RFC 5737 TEST-NET-1, never globally routed. + assert_eq!( + validate_jwks_uri("https://192.0.2.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_2() { + // 198.51.100.0/24 — RFC 5737 TEST-NET-2. + assert_eq!( + validate_jwks_uri("https://198.51.100.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_3() { + // 203.0.113.0/24 — RFC 5737 TEST-NET-3. + assert_eq!( + validate_jwks_uri("https://203.0.113.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_multicast_ip() { + // 224.0.0.1 — all-hosts multicast group (224.0.0.0/4). + assert_eq!( + validate_jwks_uri("https://224.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_reserved_class_e_ip() { + // 240.0.0.1 — reserved class E (240.0.0.0/4). + assert_eq!( + validate_jwks_uri("https://240.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_ietf_protocol_assignments_ipv4() { + // 192.0.0.0/24 — IETF Protocol Assignments (non-global by default). + // 192.0.0.1 is a representative interior address. + assert_eq!( + validate_jwks_uri("https://192.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_accepts_ietf_protocol_assignments_pcp_turn_anycast() { + // 192.0.0.9 (PCP anycast, RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155) + // are the only globally-reachable exceptions inside 192.0.0.0/24. + assert!(validate_jwks_uri("https://192.0.0.9/jwks.json").is_ok()); + assert!(validate_jwks_uri("https://192.0.0.10/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_deprecated_6to4_anycast_ipv4() { + // 192.88.99.0/24 — deprecated 6to4 relay anycast (RFC 7526). + // Registry global field is None/blank; conservative posture: block. + assert_eq!( + validate_jwks_uri("https://192.88.99.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_ietf_protocol_assignments_v6_interior() { + // 2001:2::1 — interior of 2001::/23 IETF Protocol Assignments (non-global). + assert_eq!( + validate_jwks_uri("https://[2001:2::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_accepts_ietf_protocol_assignments_v6_global_exception() { + // 2001:1::1 (PCP anycast, RFC 7723) — globally reachable exception inside 2001::/23. + assert!(validate_jwks_uri("https://[2001:1::1]/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_discard_only_v6() { + // 100::1 — 100::/64 Discard-Only address space (RFC 6666). + assert_eq!( + validate_jwks_uri("https://[100::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_v6_3fff() { + // 3fff::1 — 3fff::/20 Documentation space (RFC 9637). + assert_eq!( + validate_jwks_uri("https://[3fff::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_srv6_sids_v6() { + // 5f00::1 — 5f00::/16 SRv6 SID space (RFC 9252). + assert_eq!( + validate_jwks_uri("https://[5f00::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_credentials() { + assert_eq!( + validate_jwks_uri("https://user:pass@id.example/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_fragment() { + assert_eq!( + validate_jwks_uri("https://id.example/jwks.json#section").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_unparseable() { + assert_eq!( + validate_jwks_uri("not a url").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[tokio::test] +async fn http_fetcher_rejects_http_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("http://id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_credentials_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://user:pass@id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_fragment_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://id.example/.well-known/jwks.json#section") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_private_ip_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://10.0.0.1/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_rejects_ipv6_loopback_fast_path() { + let err = super::resolve_and_check_ssrf("::1", 443).await.unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_accepts_public_ipv6_fast_path() { + let ip = super::resolve_and_check_ssrf("2606:4700::1", 443) + .await + .unwrap(); + assert_eq!(ip, "2606:4700::1".parse::().unwrap()); +} + +/// The public fetcher rejects an IPv6 loopback JWKS URI before any network +/// connection is attempted. `fetch_jwks_inner` calls `validate_jwks_uri` as +/// its first step; `validate_jwks_uri` parses the URI, extracts the host via +/// `Url::host()`, and rejects any address matched by the shared enumerated +/// deny policy as +/// `InvalidUri`. `::1` (loopback) never reaches the extraction or +/// resolved-target enforcement stages. Bracket-free extraction and +/// resolved-target value-flow evidence is covered by the dedicated +/// `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` test; +/// connector-boundary behavior is a separate runtime concern. +#[tokio::test] +async fn http_fetcher_rejects_ipv6_loopback_uri_as_invalid() { + // https://[::1]/... is rejected by validate_jwks_uri (SSRF: loopback) + // before extraction or resolved-target enforcement runs. + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://[::1]/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!( + err, + JwksFetchError::InvalidUri, + "IPv6 loopback URI must be rejected as InvalidUri, not NetworkError" + ); +} + +/// Rejected private IPv6 site-local URI at the pre-connection SSRF boundary. +/// fec0::/10 (deprecated site-local, RFC 3879) must deny as InvalidUri. +#[tokio::test] +async fn http_fetcher_rejects_ipv6_site_local_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://[fec0::1]/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +/// `with_deadline` fires before the outer guard: removing `tokio::time::timeout` +/// inside `with_deadline` leaves the pending future unresolved and the outer guard fires. +#[tokio::test(start_paused = true)] +async fn with_deadline_fires_before_outer_guard() { + let inner = super::with_deadline( + std::future::pending::>(), + std::time::Duration::ZERO, + ); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), inner).await; + assert_eq!( + result.expect("outer guard fired — with_deadline timeout seam missing"), + Err(JwksFetchError::NetworkError), + ); +} + +// A fetcher whose per-call behaviour is scripted by an explicit sequence of steps. +// Each call pops the next step: signals `entered` on entry, then blocks until +// its release channel resolves. +struct FetchStep { + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver, +} + +struct ScriptedFetcher { + steps: std::sync::Mutex>, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for ScriptedFetcher {} + +impl JwksFetcher for ScriptedFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + let step = self.steps.lock().unwrap().pop_front(); + async move { + match step { + Some(FetchStep { entered, release }) => { + let _ = entered.send(()); + release.await.map_err(|_| JwksFetchError::NetworkError) + } + None => Err(JwksFetchError::NetworkError), + } + } + } +} + +fn script(steps: impl IntoIterator) -> ScriptedFetcher { + ScriptedFetcher { + steps: std::sync::Mutex::new(steps.into_iter().collect()), + call_count: Arc::new(AtomicUsize::new(0)), + } +} + +fn pending_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + // release_tx is returned to the caller; the fetch future is genuinely + // pending until the caller drops or sends it — not resolved immediately. + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +fn ready_step(body: String) -> (FetchStep, tokio::sync::oneshot::Receiver<()>) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + let _ = release_tx.send(body); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + ) +} + +fn blocking_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +/// A second concurrent `get_snapshot` while the first fetch is in progress must +/// not start a second fetch — the RAII permit coalesces callers. +#[tokio::test] +async fn concurrent_refresh_coalesces_without_second_fetch() { + let (step, entered_rx, release_tx) = blocking_step(); + let fetcher = script([step]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + + entered_rx.await.unwrap(); // first fetch holds the permit + + let second_result = source.get_snapshot(issuer).await; + let count_after_second = call_count.load(Ordering::SeqCst); + + let _ = release_tx.send(minimal_jwks_json("k1")); + let first_result = first.await.unwrap(); + + assert!(first_result.is_some()); + assert!(second_result.is_none()); + assert_eq!(count_after_second, 1); +} + +/// Aborting the first caller releases the RAII permit; the next call on the same +/// source fetches and succeeds. A manual boolean cleared only on success would +/// leave the permit poisoned. +#[tokio::test] +async fn aborted_first_caller_releases_permit_for_next_caller() { + let (step1, entered_rx_1, _release_tx_1) = pending_step(); + let (step2, _entered_rx_2) = ready_step(minimal_jwks_json("k2")); + + let fetcher = script([step1, step2]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + { + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + entered_rx_1.await.unwrap(); + first.abort(); + let _ = first.await; + // _release_tx_1 drops here: the fetch future was blocked on an open + // receiver when abort fired — not resolved via an error path. + } + + let result = source.get_snapshot(issuer).await; + assert!(result.is_some()); + assert_eq!(call_count.load(Ordering::SeqCst), 2); +} + +/// An expired snapshot must never be served — both `get_snapshot` and the +/// synchronous `key_set` path return `None` after the hard deadline passes. +#[tokio::test] +async fn expired_snapshot_never_served_after_hard_deadline() { + let issuer = "https://id.example"; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 1, + 2, + ) + .unwrap(), + }; + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Err::(JwksFetchError::NetworkError), + Ok(minimal_jwks_json("k1")), + ])); + struct FailAfterFirstFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for FailAfterFirstFetcher {} + impl JwksFetcher for FailAfterFirstFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + let source = ProductionJwksSource::new(vec![config], FailAfterFirstFetcher { bodies }).unwrap(); + + assert!( + source.get_snapshot(issuer).await.is_some(), + "initial fetch must succeed" + ); + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + assert!( + source.get_snapshot(issuer).await.is_none(), + "expired snapshot must not be served after hard deadline" + ); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!( + source.key_set(issuer).is_none(), + "key_set must not serve an expired snapshot" + ); +} + +/// Two issuers are fully isolated: distinct key material, independent generation +/// counters, no cross-issuer forgery. Three distinct P-256 keypairs (A1, A2, +/// B1) driven through `ProductionJwksSource` into `FederatedAssertionVerifier`. +#[tokio::test] +async fn two_issuer_keys_and_generations_are_isolated() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + + // Three genuinely distinct P-256 keypairs (PKCS#8 PEM + public JWK coords). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\n\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\n\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\n\ + -----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const PKCS8_B1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKcmDf3+zDWyC96/X\n\ + Gv8aYK552uF5aE6nXKzxAfl4fSWhRANCAATf0ccbp1c4mMd6WvSuliv5ZAS8iIWL\n\ + Ne2tqOfFa0hRpa41DANab1/EuDGi7PtIo8xSYwkaoib1MAJlfLvRMjQA\n\ + -----END PRIVATE KEY-----\n"; + const X_B1: &str = "39HHG6dXOJjHelr0rpYr-WQEvIiFizXtrajnxWtIUaU"; + const Y_B1: &str = "rjUMA1pvX8S4MaLs-0ijzFJjCRqiJvUwAmV8u9EyNAA"; + + const KID_A1: &str = "a-key-1"; + const KID_A2: &str = "a-key-2"; + const KID_B1: &str = "b-key-1"; + + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + let audience = "https://relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "iat": now, "exp": now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + fn policy(issuer: &str, aud: &str) -> IssuerPolicy { + let contract = JwksSourceContract::new( + format!( + "https://{}/jwks.json", + issuer.trim_start_matches("https://") + ), + 1, + 3600, + ) + .expect("valid contract"); + IssuerPolicy::new( + issuer.to_owned(), + vec![aud.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + contract, + ) + .expect("valid policy") + } + + fn configs(issuer_a: &str, issuer_b: &str) -> (IssuerJwksConfig, IssuerJwksConfig) { + ( + IssuerJwksConfig { + issuer: issuer_a.to_owned(), + contract: JwksSourceContract::new( + "https://a.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), + }, + IssuerJwksConfig { + issuer: issuer_b.to_owned(), + contract: JwksSourceContract::new( + "https://b.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), + }, + ) + } + + struct TwoFetcher { + a: std::sync::Mutex>, + b: String, + } + impl super::super::verifier::sealed::Sealed for TwoFetcher {} + impl JwksFetcher for TwoFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = if uri.contains("a.example") { + self.a + .lock() + .unwrap() + .pop_front() + .map(Ok) + .unwrap_or(Err(JwksFetchError::NetworkError)) + } else { + Ok(self.b.clone()) + }; + async move { result } + } + } + + let mut registry = IssuerRegistry::new(); + registry.insert(policy(issuer_a, audience)); + registry.insert(policy(issuer_b, audience)); + + // Pre-rotation: source serves A1 and B1. + let (cfg_a, cfg_b) = configs(issuer_a, issuer_b); + let pre = ProductionJwksSource::new( + vec![cfg_a, cfg_b], + TwoFetcher { + a: std::sync::Mutex::new([jwks_str(KID_A1, X_A1, Y_A1)].into()), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + pre.get_snapshot(issuer_a).await.unwrap(); + pre.get_snapshot(issuer_b).await.unwrap(); + + let v_pre = FederatedAssertionVerifier::new(registry.clone(), pre); + v_pre + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect("A1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A"); + + // Post-rotation: fresh source, A rotates A1→A2, B unchanged. + let (cfg_a2, cfg_b2) = configs(issuer_a, issuer_b); + let post = ProductionJwksSource::new( + vec![cfg_a2, cfg_b2], + TwoFetcher { + a: std::sync::Mutex::new( + [jwks_str(KID_A1, X_A1, Y_A1), jwks_str(KID_A2, X_A2, Y_A2)].into(), + ), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + post.get_snapshot(issuer_a).await.unwrap(); + post.get_snapshot(issuer_b).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let gen_a_pre = post.key_set(issuer_a).unwrap().generation(); + let gen_b_stable = post.key_set(issuer_b).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + post.get_snapshot(issuer_a).await.unwrap(); + + let gen_a_post = post.key_set(issuer_a).unwrap().generation(); + let gen_b_post = post.key_set(issuer_b).unwrap().generation(); + assert!( + gen_a_post > gen_a_pre, + "A generation must advance after rotation" + ); + assert_eq!( + gen_b_post, gen_b_stable, + "B generation must not advance when only A rotates" + ); + + let v_post = FederatedAssertionVerifier::new(registry, post); + v_post + .verify(&sign(PKCS8_A2, KID_A2, issuer_a, audience)) + .expect("A2 token must verify post-rotation"); + v_post + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect_err("old A1 token must fail after A2 rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A post-rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must still verify post-rotation"); +} + +/// Public-API regression: one long-lived [`FederatedAssertionVerifier`] backed +/// by a shared `Arc` observes key rotation through the +/// same cache it was constructed with — it does NOT need to be rebuilt when +/// keys rotate. +/// +/// Scenario: +/// A1 → initial key set (generation 1) +/// A2 → rotated key set (generation 2, committed after a refresh interval) +/// +/// The verifier is constructed once before A2 is known, then the source is +/// refreshed in-place (simulating a normal JWKS rotation). The same verifier +/// must then reject A1-signed tokens and accept A2-signed tokens, because it +/// reads from the shared cache. +/// +/// Mutation (correctness): change `Arc` to a plain +/// `ProductionJwksSource` (no sharing). The verifier would hold its own +/// copy of the pre-rotation cache and could not observe the refresh. A2 tokens +/// would fail and A1 tokens would pass — the test turns red on both assertions. +#[tokio::test] +async fn shared_arc_source_verifier_observes_rotation() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + use std::sync::Arc; + + // Two genuinely distinct P-256 keypairs (re-use the constants from the + // two-issuer test). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\n\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\n\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\n\ + -----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const KID_A1: &str = "arc-key-1"; + const KID_A2: &str = "arc-key-2"; + + let issuer = "https://arc-issuer.example"; + let audience = "https://relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "iat": now, "exp": now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + // Scripted fetcher: first call returns A1 JWKS, second call returns A2 JWKS. + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(jwks_str(KID_A2, X_A2, Y_A2)), // popped second + Ok(jwks_str(KID_A1, X_A1, Y_A1)), // popped first + ])); + + struct RotatingFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for RotatingFetcher {} + impl JwksFetcher for RotatingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let jwks_contract = + JwksSourceContract::new(format!("https://{issuer}/.well-known/jwks.json"), 1, 3600) + .unwrap(); + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: jwks_contract.clone(), + }; + + // Wrap the source in Arc — this is the sharing path under test. + let source = + Arc::new(ProductionJwksSource::new(vec![config], RotatingFetcher { bodies }).unwrap()); + + // Warm the cache with A1 JWKS. + source.get_snapshot(issuer).await.unwrap(); + + // Build the verifier from an Arc clone. This is the one long-lived + // verifier we never rebuild. + let mut registry = IssuerRegistry::new(); + registry.insert( + IssuerPolicy::new( + issuer.to_owned(), + vec![audience.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .unwrap(), + ); + let verifier = FederatedAssertionVerifier::new(registry, Arc::clone(&source)); + + // Pre-rotation: A1 token verifies. + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect("A1 token must verify before rotation"); + + // Advance past the refresh interval so the next get_snapshot triggers a + // re-fetch (which will return A2 JWKS from the scripted fetcher). + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + + // Post-rotation: the SAME verifier (never rebuilt) must now see A2 keys. + // This proves the verifier reads from the shared Arc cache, not a + // snapshot captured at construction time. + // + // Mutation: if the verifier held a plain `ProductionJwksSource` (cloned + // at construction), it would serve the pre-rotation A1 snapshot forever — + // A2 would fail and A1 would still pass, turning both assertions red. + verifier + .verify(&sign_token(PKCS8_A2, KID_A2, issuer, audience)) + .expect("A2 token must verify through the shared Arc after rotation"); + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect_err("old A1 token must be rejected after rotation (kid no longer in JWKS)"); +} + +/// **Fix 1 — URI canonicalization convergence/divergence oracle.** +/// +/// `JwksSourceContract::new` must store the `Url`-normalized form of the URI, +/// not the caller's raw input bytes. This means: +/// - An uppercase host (`EXAMPLE.COM`) normalizes to lowercase (`example.com`) +/// and produces the same `AssertionPolicyId` as the lowercase form. +/// - An explicit default HTTPS port (`:443`) is removed by `Url` normalization +/// and produces the same ID as the form without the port. +/// - A genuinely different host always produces a distinct ID. +/// +/// Mutation (correctness): changing `JwksSourceContract::new` to store the raw +/// input `jwks_uri` instead of `parsed.to_string()` causes the uppercase-host +/// and explicit-port variant tests to fail — the raw bytes differ, the SHA-256 +/// hash diverges, and `assert_eq!` on the policy IDs turns red. +#[test] +fn jwks_contract_uri_canonicalization_convergence_and_divergence() { + use crate::nip_fi::{config::IssuerPolicy, FreshnessClass, TokenClass}; + use jsonwebtoken::Algorithm; + + fn make_policy(jwks_uri: &str) -> Option { + let contract = JwksSourceContract::new(jwks_uri.to_owned(), 300, 3600)?; + IssuerPolicy::new( + "https://issuer.example".to_owned(), + vec!["https://aud.example".to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 30, + 600, + None, + contract, + ) + .ok() + .map(|p| p.id()) + } + + let canonical = + make_policy("https://issuer.example/.well-known/jwks.json").expect("canonical form"); + + // Equivalent spellings must converge after `Url` normalization. + let uppercase_host = + make_policy("https://ISSUER.EXAMPLE/.well-known/jwks.json").expect("uppercase host"); + assert_eq!( + canonical, uppercase_host, + "uppercase host must normalize to lowercase and produce identical policy ID; \ + mutation: store raw input bytes → this diverges" + ); + + let explicit_port = + make_policy("https://issuer.example:443/.well-known/jwks.json").expect("explicit port"); + assert_eq!( + canonical, explicit_port, + "explicit default HTTPS port :443 must be stripped by Url normalization; \ + mutation: store raw input bytes → this diverges" + ); + + // A genuinely different host MUST diverge (not accidentally collapse). + let different_host = + make_policy("https://other.example/.well-known/jwks.json").expect("different host"); + assert_ne!( + canonical, different_host, + "different JWKS host must produce distinct policy ID" + ); + + // A different path MUST diverge. + let different_path = + make_policy("https://issuer.example/.well-known/other-jwks.json").expect("different path"); + assert_ne!( + canonical, different_path, + "different JWKS path must produce distinct policy ID" + ); + + // Dot-segment path that resolves to the same resource MUST converge. + // `Url::parse` resolves `./jwks.json` relative paths during parsing, so + // `/.well-known/./jwks.json` normalises to `/.well-known/jwks.json`. + // Mutation: store raw input bytes -> the dot-segment form remains in the + // stored URI, the SHA-256 hash diverges, and `assert_eq!` turns red. + let dot_segment = + make_policy("https://issuer.example/.well-known/./jwks.json").expect("dot-segment path"); + assert_eq!( + canonical, dot_segment, + "dot-segment-equivalent path must normalize and produce identical policy ID; \ + mutation: store raw input bytes -> this diverges" + ); +} + +/// **Fix 2 — Public bracketed-IPv6 JWKS URI through the resolved-target and pin-input seam.** +/// +/// This seam test is network-free: both public `2606:4700::1` and site-local +/// `fec0::1` are IP literals, so `resolve_and_check_ssrf` takes the fast path +/// (`host.parse::()` then `is_not_global_unicast`) without any DNS +/// lookup. +/// +/// The seam covers the three stages `fetch_jwks_inner` traverses in order: +/// 1. `extract_url_host_and_port` — typed `Url::host()` yields bare +/// `"2606:4700::1"`, not the bracketed `"[2606:4700::1]"` that +/// `host_str()` returns. +/// 2. `resolve_and_check_ssrf(host, port)` — fast path: `host.parse::()` +/// succeeds only for the bare form, passes `is_not_global_unicast`, and +/// returns the `IpAddr`. +/// 3. Reqwest `.resolve(host, SocketAddr::new(ip, port))` uses the raw `host` +/// string as its pin key. The key must equal the URL authority form — +/// bare for IPv6, brackets forbidden. +/// +/// This test proves that the extracted host string is bare (the correct input +/// form for `reqwest::ClientBuilder::resolve`). It does not exercise the +/// reqwest connector; connector-boundary behavior is a runtime concern. +/// +/// For `fec0::1`: `extract_url_host_and_port` still extracts the bare address; +/// `resolve_and_check_ssrf` rejects it via `is_not_global_unicast`. +/// +/// ## Mutation oracle +/// Replace `Some(url::Host::Ipv6(addr)) => addr.to_string()` with +/// `Some(url::Host::Ipv6(addr)) => format!("[{}]", addr)` in +/// `extract_url_host_and_port`. The bracketed string is returned. +/// - `"[2606:4700::1]".parse::()` fails → SSRF fast path unreachable +/// → public acceptance assertion flips red. +/// - `is_not_global_unicast` is never called on `fec0::1` (the parse also +/// fails) → `resolve_and_check_ssrf` returns `NetworkError` not `InvalidUri` +/// → fec0 rejection-kind assertion flips red. +/// - The pin-input equality assertion also flips red (bracket mismatch). +#[tokio::test] +async fn resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection() { + use buzz_core::network::is_not_global_unicast; + + // ── Stage 1: extraction ─────────────────────────────────────────────────── + let uri = "https://[2606:4700::1]/.well-known/jwks.json"; + let (host, port) = + super::extract_url_host_and_port(uri).expect("public IPv6 URI must be parseable"); + assert_eq!( + host, "2606:4700::1", + "host must be bare (mutation: bracket → IpAddr::parse fails)" + ); + assert_eq!(port, 443u16, "default HTTPS port"); + + // ── Stage 2: IpAddr resolution (SSRF fast path) ─────────────────────────── + // `host.parse::()` succeeds only for the bare form. This is exactly + // the fast path in `resolve_and_check_ssrf` that bypasses DNS. + let ip: std::net::IpAddr = host + .parse() + .expect("bracket-free host must parse as IpAddr; mutation: bracketed form fails here"); + assert!(ip.is_ipv6(), "must be an IPv6 address"); + + // `is_not_global_unicast` must return false for a public address. + assert!( + !is_not_global_unicast(&ip), + "2606:4700::1 must pass as globally reachable; mutation: SSRF check would reject it" + ); + + // Confirm resolve_and_check_ssrf accepts the public address (network-free fast path). + let resolved = super::resolve_and_check_ssrf(&host, port) + .await + .expect("public IPv6 must be accepted by SSRF check"); + assert_eq!( + resolved, ip, + "resolved address must equal the IpAddr parsed from the bare host" + ); + + // ── Stage 3: pin-key string form ──────────────────────────────────────── + // The host string extracted by `extract_url_host_and_port` is the value + // passed to reqwest's `.resolve(host, ...)`. For a reqwest pin to apply, + // the key passed to `.resolve()` must equal the URL authority form. For + // IPv6 literals the URL authority form is bare (no brackets), so the + // extracted host must also be bare. This assertion verifies that the + // extracted host string is bare — it does not directly exercise the + // reqwest connector, but proves the input to the pin call is correct. + let socket_addr = std::net::SocketAddr::new(resolved, port); + let expected_pin_key = "2606:4700::1"; + assert_eq!( + host, expected_pin_key, + "extracted host must equal the bare URL authority for use as reqwest pin key; \ + mutation: bracketed extraction returns \"[2606:4700::1]\" (differs from authority form)" + ); + // Sanity: confirm the SocketAddr is valid (no panic = key formation succeeded). + let _ = socket_addr; + + // ── fec0::/10 rejection through the same seam ──────────────────────────── + // Stage 1: extraction succeeds (SSRF decision is downstream). + let fec0_uri = "https://[fec0::1]/.well-known/jwks.json"; + let (fec0_host, fec0_port) = + super::extract_url_host_and_port(fec0_uri).expect("extraction succeeds for fec0 URI"); + assert_eq!(fec0_host, "fec0::1", "fec0 host must be bare"); + assert_eq!(fec0_port, 443u16); + + // Stage 2: IpAddr parse succeeds for the bare form. + let fec0_ip: std::net::IpAddr = fec0_host + .parse() + .expect("bracket-free fec0 host parses as IpAddr; mutation: bracketed form fails here"); + + // is_not_global_unicast must block fec0::/10 (deprecated site-local, RFC 3879). + assert!( + is_not_global_unicast(&fec0_ip), + "fec0::1 must be rejected by is_not_global_unicast; mutation: wrong bracket form \ + bypasses this check (parse fails, NetworkError not InvalidUri)" + ); + + // resolve_and_check_ssrf must return InvalidUri for fec0::1. + let fec0_err = super::resolve_and_check_ssrf(&fec0_host, fec0_port) + .await + .unwrap_err(); + assert_eq!( + fec0_err, + JwksFetchError::InvalidUri, + "fec0::1 must be rejected as InvalidUri, not NetworkError; \ + mutation: bracketed form -> parse fails -> DNS path -> NetworkError (red)" + ); +} + +/// **Fix 3 — Unchanged verifier observes A1→A2 rotation beyond A1's original absolute deadline.** +/// +/// Uses an injectable clock (`new_with_clock`) to advance controlled `now` past +/// A1's immutable hard deadline without wall-clock sleep. A1's deadline is +/// computed at first-fetch time (T0) and never mutated. The clock then advances +/// to T0 + HARD_DEADLINE_SECS + 1, beyond A1's original absolute deadline. +/// `get_snapshot` fires because the snapshot is expired, fetches A2, and the +/// one unchanged verifier (never rebuilt) must reflect the new keys. +/// +/// ## Mutation oracles +/// 1. **Sharing:** Replace `Arc::clone(&source)` passed to the verifier with a +/// fresh `Arc::new(second_source)` built from the same configs but independent, +/// sharing the same controlled clock. Warm the independent source with a +/// separate A1 fetch before advancing the clock. After advancement, +/// `key_set()` on the verifier's independent source filters the expired A1 +/// snapshot (`filter(|c| now < c.hard_deadline)`) and returns no keys — +/// the verifier never re-fetches and never observes A2. The A2-accept +/// assertion flips red reliably, because the verifier never observes A2. +/// The A1-reject assertion stays green: the independent cache is also +/// expired (same advanced clock), so that source also returns no A1 keys — +/// A1 tokens are still rejected, but through expiry of the independent +/// cache rather than through shared-arc rotation. **A2 acceptance is the +/// reliable shared-source oracle here.** +/// +/// Note: the expiry-purge (`state.snapshot = None` in `get_snapshot`) is +/// correctness-critical for concurrent callers: it clears the expired snapshot +/// before permit acquisition, so a caller that loses the permit race and falls +/// back to `state.snapshot` receives `None` rather than an expired snapshot. +/// A1 rejection after the deadline is also enforced independently by the `key_set` +/// read path (`filter(|c| now < c.hard_deadline)`), but the purge is what +/// prevents the fallback path from serving a stale snapshot to concurrent +/// refresh losers, so no separate purge mutation oracle is claimed here. +#[tokio::test] +async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + use std::sync::atomic::{AtomicI64, Ordering}; + use std::sync::Arc; + + // Two distinct P-256 keypairs (reuse constants from shared_arc test). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\ + \n-----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\ + \n-----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const KID_A1: &str = "exp-key-1"; + const KID_A2: &str = "exp-key-2"; + const HARD_DEADLINE_SECS: u64 = 3600; + + let issuer = "https://exp-issuer.example"; + let audience = "https://exp-relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let wall_now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "iat": wall_now, "exp": wall_now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + // Scripted fetcher: first call -> A1, second call -> A2. + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(jwks_str(KID_A2, X_A2, Y_A2)), // popped second + Ok(jwks_str(KID_A1, X_A1, Y_A1)), // popped first + ])); + + struct RotatingFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for RotatingFetcher {} + impl JwksFetcher for RotatingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let jwks_contract = JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + HARD_DEADLINE_SECS, + ) + .unwrap(); + + // Controlled clock: atomic epoch-seconds, starts at real T0. + let t0 = chrono::Utc::now().timestamp(); + let clock = Arc::new(AtomicI64::new(t0)); + let clock2 = Arc::clone(&clock); + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + chrono::DateTime::from_timestamp(clock2.load(Ordering::SeqCst), 0) + .unwrap_or(chrono::DateTime::UNIX_EPOCH) + }); + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: jwks_contract.clone(), + }; + // Mutation oracle 1 (sharing): pass a second independent Arc to the verifier, + // separately warmed with A1 before advancing the clock. After advancement, + // A2-accept flips red (verifier never observes A2 keys); A1-reject stays + // green (independent cache also expired, so A1 keys are absent there too). + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![config], + RotatingFetcher { bodies }, + Arc::clone(&now_fn), + ) + .unwrap(), + ); + + // Step 1: warm cache with A1 JWKS (first scripted fetch at T0). + let snap_a1 = source.get_snapshot(issuer).await.unwrap(); + let gen_a1 = snap_a1.generation(); + // A1's hard deadline is T0 + HARD_DEADLINE_SECS; never mutated by this test. + let deadline_a1 = snap_a1.hard_deadline(); + + // Step 2: build the ONE long-lived verifier. + let mut registry = IssuerRegistry::new(); + registry.insert( + IssuerPolicy::new( + issuer.to_owned(), + vec![audience.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + HARD_DEADLINE_SECS, + None, + jwks_contract, + ) + .unwrap(), + ); + let verifier = FederatedAssertionVerifier::new(registry, Arc::clone(&source)); + + // Pre-advancement: A1 verifies. + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect("A1 token must verify before clock advances past its deadline"); + + // Step 3: advance clock past A1's original hard deadline (no sleep). + clock.store(t0 + HARD_DEADLINE_SECS as i64 + 1, Ordering::SeqCst); + + // Step 4: re-fetch through the SAME shared source. + // Expiry purge fires (now > A1 deadline), second scripted response is A2. + let snap_a2 = source.get_snapshot(issuer).await.unwrap(); + let gen_a2 = snap_a2.generation(); + let deadline_a2 = snap_a2.hard_deadline(); + + assert!( + gen_a2 > gen_a1, + "generation must advance: A1={gen_a1} A2={gen_a2}" + ); + // A2's deadline is computed at advanced clock time, so it is later than A1's. + assert!( + deadline_a2 > deadline_a1, + "A2 deadline must be later than A1's original" + ); + + // Step 5: the SAME unchanged verifier reflects A2 keys. + verifier + .verify(&sign_token(PKCS8_A2, KID_A2, issuer, audience)) + .expect( + "A2 token must verify through the unchanged verifier after A1 deadline expired; \ + mutation oracle: use independent Arc -> A2-accept flips red (reliable oracle)", + ); + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect_err("A1 must be rejected after expiry + rotation"); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index f7d1243a058..ce977090645 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,32 +1,19 @@ -//! NIP-FI federated-identity authorization — canonical assertion verifier and -//! contracts (Phase A, PR 1). -//! -//! This module is the closed, provider-neutral contract layer at the root of -//! the NIP-FI dependency graph. It defines: -//! -//! - the multi-issuer assertion-policy [`config`] and the two deterministic -//! semantic contract identities ([`AssertionPolicyId`], -//! [`TransportContractId`]); -//! - the origin-sealed normalized [`VerifiedAssertion`] result (`FI-INV-16`); -//! - the single [`FederatedAssertionVerifier`] (`FI-INV-16` canonical verifier); -//! - the privacy-preserving four-class [`DenialClass`] wire contract -//! (`FI-INV-13`). -//! -//! It has no dependencies on other NIP-FI PRs. It defines no database schema, -//! migration, runtime JWKS fetching, binding resolution, enrollment, or -//! request/proof binding — those belong to later PRs. Identity is issuer- -//! qualified `(iss, sub)` throughout: the `sub` claim is the fixed subject -//! coordinate and `nostr_pubkey` is the fixed key claim, never configurable, -//! so no deployment can seal a mutable attribute as identity. Issuer URL and -//! audience remain deployment configuration. +//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, +//! startup validation, and discovery. -/// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), -/// "Client-attached transport"). `Authorization` remains reserved for NIP-98. +/// The client-attached transport header for federated-identity assertions. +/// +/// `Authorization` remains reserved for NIP-98; this separate header avoids +/// conflating authentication schemes at the relay ingress. +/// ([NIP-FI.md](../../../docs/nips/NIP-FI.md), "Client-attached transport") pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; pub mod config; pub mod denial; +pub mod discovery; +pub mod jwks; +pub mod startup; pub mod verifier; pub use assertion::{ @@ -39,4 +26,12 @@ pub use config::{ NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; +pub use discovery::{ + AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, +}; +pub use jwks::{ + HttpJwksFetcher, IssuerJwksConfig, JwksFetchError, JwksFetcher, JwksSourceContract, + ProductionJwksSource, +}; +pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs new file mode 100644 index 00000000000..410c862ec70 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -0,0 +1,134 @@ +//! Startup validation for the NIP-FI assertion runtime. +//! +//! [`validate_nip_fi_config`] is the production entry point. It rejects any +//! configuration that would make the runtime unsafe, incomplete, or ambiguous +//! before the relay accepts any protected traffic. The relay MUST call this and +//! refuse to start on error in [`Enforce`][NipFiMode::Enforce] mode +//! (`FI-INV-14`, `FI-INV-15`). + +use super::config::{FreshnessClass, IssuerRegistry}; +use super::jwks::IssuerJwksConfig; + +/// Variant names are stable contract values; do not rename without a +/// `VERIFIER_CONTRACT_VERSION` bump. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NipFiMode { + /// NIP-FI is disabled. Protected ingresses are unreachable or absent. + Off, + /// Production enforcement: every protected ingress requires valid + /// federated assertion evidence. The relay MUST call + /// [`validate_nip_fi_config`] before accepting traffic in this mode. + Enforce, + /// All protected routes deny unconditionally. Used when a prior + /// enforce-mode deployment was misconfigured and must fail closed while + /// the operator repairs configuration. [FI-INV-14] + DenyProtected, +} + +/// Every variant corresponds to a concrete, operator-actionable defect. +/// No key material, token bytes, or raw claim values appear. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum NipFiStartupError { + /// Registry has no entries; enforce mode requires at least one issuer. + #[error("NIP-FI enforce mode requires at least one issuer policy")] + EmptyRegistry, + + /// The duplicate `iss` is omitted to avoid leaking configuration into + /// operational logs. + #[error("NIP-FI issuer registry contains a duplicate issuer")] + DuplicateIssuer, + + /// Every registered issuer requires a JWKS endpoint in enforce mode. + #[error("NIP-FI issuer has no JWKS configuration")] + MissingJwksConfig, + + /// Mismatched configs are rejected to prevent silent key-source confusion. + #[error("NIP-FI JWKS config issuer does not match any registered policy")] + UnmatchedJwksConfig, + + /// The `JwksSourceContract` embedded in the `IssuerJwksConfig` does not + /// match the contract in the corresponding `IssuerPolicy`. Both must carry + /// exactly the same contract to keep a single source of truth per issuer. + #[error("NIP-FI JWKS config contract does not match the registered policy contract")] + JwksContractMismatch, + + /// `current-status` requires an authenticated status witness that is not + /// yet implemented. Use `FreshnessClass::OfflineJwt` instead. + #[error( + "NIP-FI current-status freshness is not yet supported; \ + use offline-jwt posture" + )] + UnsupportedPosture, +} + +/// Validates the complete NIP-FI runtime configuration. On error the relay +/// MUST refuse to start or fall back to [`NipFiMode::DenyProtected`]. +pub fn validate_nip_fi_config( + mode: NipFiMode, + registry: &IssuerRegistry, + jwks_configs: &[IssuerJwksConfig], +) -> Result<(), NipFiStartupError> { + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(()); + } + + if registry.is_empty() { + return Err(NipFiStartupError::EmptyRegistry); + } + + // IssuerRegistry overwrites duplicates silently; assert uniqueness here so + // a misconfigured multi-issuer call-site is caught before traffic is served. + { + let mut seen = std::collections::HashSet::new(); + for policy in registry.all_policies() { + if !seen.insert(policy.issuer()) { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + } + + // Reject current-status policies: the status witness is not yet + // implemented. Fail closed rather than advertise a freshness guarantee the + // verifier cannot satisfy. + for policy in registry.all_policies() { + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(NipFiStartupError::UnsupportedPosture); + } + } + + // Build JWKS map, rejecting duplicates. Two configs for the same issuer + // would make the effective endpoint selection order-dependent. + let mut jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = + std::collections::HashMap::with_capacity(jwks_configs.len()); + for config in jwks_configs { + if jwks_map.insert(config.issuer.as_str(), config).is_some() { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + + for config in jwks_configs { + if registry.policy_for_issuer(&config.issuer).is_none() { + return Err(NipFiStartupError::UnmatchedJwksConfig); + } + // Contract fields are pre-validated inside `JwksSourceContract::new` + // at `IssuerPolicy` construction. Enforce that the config carries the + // same contract as the policy — a mismatch would mean two independent + // copies of the URI/timing drifted apart, violating the single-source- + // of-truth invariant. + let policy = registry.policy_for_issuer(&config.issuer).unwrap(); + if &config.contract != policy.jwks_source_contract() { + return Err(NipFiStartupError::JwksContractMismatch); + } + } + + for policy in registry.all_policies() { + if !jwks_map.contains_key(policy.issuer()) { + return Err(NipFiStartupError::MissingJwksConfig); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs new file mode 100644 index 00000000000..9b1d59b1877 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -0,0 +1,180 @@ +use super::*; +use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; +use crate::nip_fi::jwks::{IssuerJwksConfig, JwksSourceContract}; +use jsonwebtoken::Algorithm as JwtAlgorithm; + +fn test_contract(issuer: &str) -> JwksSourceContract { + // Build a canonical JWKS URI from the issuer URL. The issuer may already + // be a full HTTPS URL (e.g. "https://id.example") or a bare hostname. + let uri = if issuer.starts_with("https://") { + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')) + } else { + format!("https://{}/.well-known/jwks.json", issuer) + }; + JwksSourceContract::new(uri, 300, 3600).expect("valid test contract") +} + +fn make_offline_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![JwtAlgorithm::ES256], + 0, + 3600, + None, + test_contract(issuer), + ) + .unwrap() +} + +fn make_status_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::CurrentStatus, + vec![JwtAlgorithm::ES256], + 0, + 3600, + Some(60), + test_contract(issuer), + ) + .unwrap() +} + +fn make_jwks_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: test_contract(issuer), + } +} + +#[test] +fn off_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::Off, ®istry, &[]).is_ok()); +} + +#[test] +fn deny_protected_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::DenyProtected, ®istry, &[]).is_ok()); +} + +#[test] +fn enforce_valid_config_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]).is_ok() + ); +} + +#[test] +fn enforce_multiple_issuers_passes() { + let issuers = [ + "https://a.example", + "https://b.example", + "https://c.example", + ]; + let mut registry = IssuerRegistry::new(); + for iss in &issuers { + registry.insert(make_offline_policy(iss)); + } + let jwks: Vec<_> = issuers.iter().map(|i| make_jwks_config(i)).collect(); + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} + +#[test] +fn enforce_empty_registry_rejects() { + let registry = IssuerRegistry::new(); + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::EmptyRegistry); +} + +#[test] +fn enforce_issuer_without_jwks_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::MissingJwksConfig); +} + +#[test] +fn enforce_unmatched_jwks_config_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config( + NipFiMode::Enforce, + ®istry, + &[make_jwks_config("https://other.example")], + ) + .unwrap_err(); + assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); +} + +/// A JWKS config whose contract differs from the policy contract must be +/// rejected — a mismatch means two independent copies of URI/timing have +/// drifted, violating the single-source-of-truth invariant. +#[test] +fn enforce_jwks_contract_mismatch_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + // Config carries a different refresh interval than the policy (300 vs 600). + let mismatched_config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 600, // differs from policy contract (300) + 3600, + ) + .unwrap(), + }; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[mismatched_config]).unwrap_err(), + NipFiStartupError::JwksContractMismatch + ); +} + +/// Rejected regardless of whether a JWKS config is present — the verifier +/// has no status witness to satisfy the freshness guarantee. +#[test] +fn enforce_current_status_policy_always_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]) + .unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); +} + +/// Duplicate JWKS configs for the same issuer must not silently succeed. +#[test] +fn enforce_duplicate_jwks_issuer_in_configs_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer), make_jwks_config(issuer)]; + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_err(), + "duplicate JWKS configs must not pass" + ); +} diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 7ac2cbe3766..cf20b57a86e 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -49,9 +49,13 @@ use std::fmt; /// the key-source trait. Combined with the crate-private [`AssertionKeySet`] /// constructor, this makes the accepted issuer→JWKS authority impossible to /// synthesize outside the crate's trusted configuration path. -mod sealed { +pub(crate) mod sealed { /// Private marker preventing external implementations of the key source. pub trait Sealed {} + + // Blanket seal for `Arc` so `Arc` satisfies + // the sealed supertrait without requiring callers to implement it. + impl Sealed for std::sync::Arc {} } /// One issuer's key source: a JWKS snapshot bound to the exact `iss` it @@ -64,7 +68,7 @@ mod sealed { /// construction seam: [`verify`] takes no snapshot argument, and this type has /// no public constructor, so an external consumer cannot build a snapshot that /// labels issuer B's JWKS as issuer A. Building a snapshot (and the source that -/// serves it) is the trusted configuration act PR 3's JWKS runtime performs at +/// serves it) is the trusted configuration act the `jwks` runtime performs at /// startup, not a per-request or external input. /// /// The crate-private constructor is a live regression: an external crate that @@ -90,7 +94,7 @@ impl AssertionKeySet { /// generation and a required key-snapshot hard deadline. Rejects a zero /// generation, an empty issuer, an empty or oversized key set /// ([`MAX_JWKS_KEYS`]), or a non-positive deadline. Crate-private: only the - /// trusted in-crate configuration path (PR 3's JWKS runtime) may bind key + /// trusted in-crate configuration path (the `jwks` runtime) may bind key /// material to an issuer. /// /// Bounding the key count here is the pre-lookup control (NIP-FI.md:166-171): @@ -101,13 +105,6 @@ impl AssertionKeySet { /// finite key-snapshot bound into `revalidation_dependencies` /// (NIP-FI.md:240-249). /// - /// Its only current callers are the in-crate `cfg(test)` verifier suite; - /// PR 3's JWKS runtime is the intended non-test consumer. Until it lands the - /// non-test lib build sees no caller, so this narrowly allows `dead_code` - /// for this one constructor rather than deferring it or widening the lint. - /// `expect` would misfire: under `cfg(test)` the lint does not trigger, so - /// the expectation would be unfulfilled and fail `-D warnings`. - #[allow(dead_code)] pub(crate) fn new( issuer: String, generation: u64, @@ -139,6 +136,13 @@ impl AssertionKeySet { pub const fn generation(&self) -> u64 { self.generation } + + /// The snapshot hard deadline. Test-only accessor for deadline-crossing + /// oracles; not compiled into production builds. + #[cfg(test)] + pub(crate) fn hard_deadline(&self) -> chrono::DateTime { + self.hard_deadline + } } impl fmt::Debug for AssertionKeySet { @@ -153,7 +157,7 @@ impl fmt::Debug for AssertionKeySet { /// instead asks this source for the snapshot bound to the token's /// signature-authenticated `iss`. A request-path caller therefore cannot /// relabel one issuer's JWKS as another's — the cross-issuer bypass at the old -/// `verify(token, key_set)` seam. Configuring the source (PR 3's JWKS runtime) +/// `verify(token, key_set)` seam. Configuring the source (the `jwks` runtime) /// is a trusted startup act, not per-request input. /// /// This trait is sealed via a private supertrait, so it cannot be implemented @@ -180,8 +184,27 @@ pub trait IssuerKeySource: sealed::Sealed { fn key_set(&self, issuer: &str) -> Option; } +/// Forwarding implementation so a single `Arc` can be cheaply cloned and +/// shared across multiple [`FederatedAssertionVerifier`] instances while all +/// of them observe every refresh committed to the shared source. +/// +/// This is the canonical sharing path for `ProductionJwksSource`, which is +/// not itself `Clone` (its internal `RwLock`-protected state is not cheaply +/// copyable). Wrap it in `Arc` at startup, then pass `Arc::clone(&source)` to +/// each verifier — all verifiers read from the same underlying cache and see +/// key rotations as soon as `get_snapshot` commits them. +/// +/// The blanket seal (`impl Sealed for Arc`) in the `sealed` +/// module ensures this forwarding impl remains crate-owned: an external crate +/// still cannot implement `IssuerKeySource` for its own type. +impl IssuerKeySource for std::sync::Arc { + fn key_set(&self, issuer: &str) -> Option { + (**self).key_set(issuer) + } +} + /// A fixed issuer→snapshot key source for the in-crate verifier tests, -/// standing in for PR 3's JWKS runtime. It is `cfg(test)`-only — not behind a +/// standing in for the `jwks` runtime. It is `cfg(test)`-only — not behind a /// downstream-selectable Cargo feature — so no dependent crate can enable it to /// reconstruct the authority. An honest source returns only the snapshot bound /// to the exact issuer requested, the invariant the real runtime source @@ -339,7 +362,7 @@ impl FederatedAssertionVerifier { enforce_claim_semantics(policy, &claims)?; let subject = claim_string(&claims, SUBJECT_CLAIM, MAX_SUBJECT_BYTES)?; - let asserted_key = parse_nostr_pubkey_claim(policy, &claims)?; + let asserted_key = parse_nostr_pubkey_claim(&claims)?; let now = Utc::now(); let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?; @@ -352,7 +375,7 @@ impl FederatedAssertionVerifier { // is `evidence_rejected` (403), and this defers a valid one as // `authorization_unavailable` (503) so a missing witness never // masquerades as rejected evidence, nor invalid input as unavailable - // (NIP-FI.md:459-476). PR 3 adds the witness path additively. + // (NIP-FI.md:459-476). if policy.freshness() == FreshnessClass::CurrentStatus { return Err(VerifierError::StatusWitnessUnavailable); } @@ -697,20 +720,13 @@ fn enforce_claim_semantics( } /// Parse the fixed `nostr_pubkey` claim: lowercase hex of exactly one 32-byte -/// key. Bech32 and other aliases deny. Absence is permitted unless the policy -/// requires an attested key. +/// key. Bech32 and other aliases deny. Absence denies; the merged NIP-FI +/// spec v2 (PR #7214) requires the `nostr_pubkey` claim unconditionally. fn parse_nostr_pubkey_claim( - policy: &IssuerPolicy, claims: &Map, ) -> Result, VerifierError> { match claims.get(NOSTR_PUBKEY_CLAIM) { - None => { - if policy.require_attested_key() { - Err(VerifierError::ClaimRejected) - } else { - Ok(None) - } - } + None => Err(VerifierError::ClaimRejected), Some(value) => { let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?; if raw.len() != 64 @@ -726,8 +742,8 @@ fn parse_nostr_pubkey_claim( } } -/// Capture only the claim names the policy reads into a canonical set. For PR 1 -/// the closed set is the `scope` claim, split on ASCII space; unchecked claims +/// Capture only the claim names the policy reads into a canonical set. The +/// closed set is the `scope` claim, split on ASCII space; unchecked claims /// never enter the result. fn capture_capabilities( _policy: &IssuerPolicy, diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 316681e0afc..8f6c60c40ae 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -27,6 +27,20 @@ const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; const TEST_KID: &str = "test-key-1"; const ISSUER: &str = "https://issuer.example"; const AUDIENCE: &str = "https://relay.example"; +/// A canonical lowercase-hex nostr pubkey for tokens that are not testing +/// the nostr_pubkey claim specifically. Spec v2 requires the claim unconditionally. +const TEST_NOSTR_PUBKEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + +/// A canonical JWKS contract for the default test issuer. Used wherever a +/// `JwksSourceContract` is required but JWKS behavior is not under test. +fn test_jwks_contract() -> crate::nip_fi::jwks::JwksSourceContract { + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .expect("valid test contract") +} // A second, independent P-256 key: issuer B's real signing key, used to prove // that a token signed by B and claiming `iss=A` cannot mint an A identity. @@ -98,25 +112,31 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy TokenClass::AccessTokenAtJwt { subject_class }, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } fn dedicated_policy(issuer: &str) -> IssuerPolicy { + let contract = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 300, + 3600, + ) + .expect("valid test contract"); IssuerPolicy::new( issuer.to_owned(), vec![AUDIENCE.to_owned()], TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, + contract, ) .expect("valid policy") } @@ -128,10 +148,10 @@ fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -143,10 +163,10 @@ fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, algorithms, - false, 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -177,6 +197,10 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val obj.entry("aud").or_insert(json!(AUDIENCE)); obj.entry("iat").or_insert(json!(now())); obj.entry("exp").or_insert(json!(now() + 600)); + // Spec v2 requires nostr_pubkey unconditionally; inject a canonical + // test pubkey so tokens that test other behaviours pass the claim check. + obj.entry(NOSTR_PUBKEY_CLAIM) + .or_insert(json!(TEST_NOSTR_PUBKEY)); } let mut header = Header::new(Algorithm::ES256); header.kid = Some(kid.to_owned()); @@ -185,6 +209,26 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val jsonwebtoken::encode(&header, &claims, &key).expect("sign") } +/// Mint a valid, signed token that deliberately omits `nostr_pubkey`. Used +/// only to exercise the unconditional missing-claim rejection path; the normal +/// `mint`/`mint_signed_by` helpers always inject the claim via `or_insert` so +/// they cannot produce an absent-claim token. +fn mint_no_pubkey(typ: Option<&str>, kid: &str, mut claims: Value) -> String { + { + let obj = claims.as_object_mut().expect("claims object"); + obj.entry("iss").or_insert(json!(ISSUER)); + obj.entry("aud").or_insert(json!(AUDIENCE)); + obj.entry("iat").or_insert(json!(now())); + obj.entry("exp").or_insert(json!(now() + 600)); + // Intentionally does NOT inject nostr_pubkey. + } + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(kid.to_owned()); + header.typ = typ.map(str::to_owned); + let key = EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign") +} + /// A resource-owner `at+jwt` claim set: valid subject-class marker plus client_id. fn resource_owner_claims() -> Value { json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user" }) @@ -219,7 +263,8 @@ fn valid_access_token_verifies() { let assertion = verifier.verify(&token).expect("verifies"); assert_eq!(assertion.identity().issuer(), ISSUER); assert_eq!(assertion.identity().subject(), "user-123"); - assert!(assertion.asserted_key().is_none()); + // Spec v2: nostr_pubkey is injected by mint() and unconditionally required. + assert!(assertion.asserted_key().is_some()); assert!(!assertion.authority_deadlines().is_empty()); assert_eq!(assertion.assertion_policy_id(), access_token_policy().id()); } @@ -677,21 +722,18 @@ fn uppercase_nostr_pubkey_denies() { } #[test] -fn missing_nostr_pubkey_denies_under_attested_key_policy() { - let policy = IssuerPolicy::new( - ISSUER.to_owned(), - vec![AUDIENCE.to_owned()], - TokenClass::DedicatedNipFi, - FreshnessClass::OfflineJwt, - vec![Algorithm::ES256], - true, // require attested key - 60, - 3600, - None, - ) - .unwrap(); - let verifier = verifier_with(policy); - let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); +fn absent_nostr_pubkey_claim_denies() { + // `nostr_pubkey` absence must unconditionally reject — NIP-FI v2 dropped + // the per-issuer `require_attested_key` knob that previously made it + // optional. This is a direct falsifiable regression test: removing the + // `None => Err(VerifierError::ClaimRejected)` arm from + // `parse_nostr_pubkey_claim` must turn this test red. + let verifier = verifier_with(access_token_policy()); + let token = mint_no_pubkey( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user" }), + ); assert_eq!( verifier.verify(&token).unwrap_err(), VerifierError::ClaimRejected @@ -1083,10 +1125,10 @@ fn current_status_policy() -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(120), // maximum_status_age required for current-status + test_jwks_contract(), ) .expect("valid current-status policy") } @@ -1362,10 +1404,10 @@ fn assertion_policy_id_is_deterministic_and_semantic() { changed.token_class().clone(), FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 120, // different skew => different semantics 3600, None, + test_jwks_contract(), ) .unwrap(); assert_ne!(p1.id(), changed.id()); @@ -1387,10 +1429,10 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, Some(120), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(err, IssuerPolicyError::InapplicableMaximumStatusAge); @@ -1405,10 +1447,10 @@ fn offline_policy_accepts_absent_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, + test_jwks_contract(), ) .is_ok()); } @@ -1423,10 +1465,10 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, None, + test_jwks_contract(), ) .unwrap_err(); assert_eq!(missing, IssuerPolicyError::MissingMaximumStatusAge); @@ -1436,10 +1478,10 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(0), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(zero, IssuerPolicyError::InvalidTimeBounds); @@ -1533,7 +1575,184 @@ fn assertion_policy_id_is_invariant_under_subject_class_value_permutation_and_du assert_eq!(base.id(), permuted.id()); } -// ---- Canonical scope capture --------------------------------------------- +// ---- JwksSourceContract in AssertionPolicyId ------------------------------ +// +// Per the NIP-FI spec ("Policy identity and snapshots"): `assertion_policy_id` +// covers "authenticated key/status-source contracts" and "time rules". The +// three contract fields are immutable contract identity, not mutable state — +// changing any one of them changes which keys the runtime trusts or how long +// it trusts them, invalidating all prepared evidence against the old contract. +// Key rotation (JWKS content change) leaves all three unchanged and must NOT +// move the ID. + +/// Helper: build a policy with the given `JwksSourceContract`. +fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + contract, + ) + .expect("valid policy") +} + +#[test] +fn assertion_policy_id_moves_when_jwks_uri_changes() { + // The JWKS URI selects the authenticated key source. A different URI may + // serve different keys — the policy ID must change. + // + // Mutation (omit URI from hash): both policies hash identically despite + // different endpoints; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_uri = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks-alt.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_uri.id(), + "JWKS URI change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_refresh_interval_changes() { + // The refresh interval defines bounded refresh behavior. A longer interval + // allows stale keys to persist longer — the policy ID must change. + // + // Mutation (omit refresh_interval from hash): both policies hash + // identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_interval = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 600, // doubled + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_interval.id(), + "refresh_interval_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_hard_deadline_changes() { + // The hard deadline defines the source's accepted time rule; every + // per-snapshot deadline the verifier seals into `VerifiedAssertion` + // derives from this. A looser deadline extends the valid window beyond + // what the new policy intends — the policy ID must change. + // + // Mutation (omit key_snapshot_hard_deadline from hash): both policies + // hash identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_deadline = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 7200, // doubled + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_deadline.id(), + "key_snapshot_hard_deadline_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_is_stable_for_same_jwks_contract() { + // URI canonicalization is deterministic: the same validated URI, interval, + // and deadline always hash to the same policy ID regardless of call order. + let c1 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let c2 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let p1 = policy_with_contract(c1); + let p2 = policy_with_contract(c2); + assert_eq!( + p1.id(), + p2.id(), + "same JWKS contract must produce identical assertion_policy_id" + ); +} + +#[test] +fn identical_contract_produces_stable_assertion_policy_id() { + // `AssertionPolicyId` is derived from the contract fields only — not from + // JWKS key material. This means JWKS key additions/removals (runtime + // rotation) cannot change the policy ID; only changes to the contract + // itself (JWKS URI, refresh interval, hard deadline) would do so. + // + // This test verifies the structural invariant: two `IssuerPolicy` values + // built from identical contracts produce the same `AssertionPolicyId`, + // regardless of when or how many times the ID is derived. Because key + // material never flows into `derive_assertion_policy_id`, the ID is + // stable for the lifetime of a given contract. + let p1 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let p2 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + // Identical contract → identical ID: key material is not part of the hash. + assert_eq!( + p1.id(), + p2.id(), + "identical contract must produce the same assertion_policy_id (key material is not hashed)" + ); +} #[test] fn scope_capture_is_canonical_under_order_and_duplicates() { 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 index beffc294408..fb291a7db54 100644 --- 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 @@ -26,6 +26,7 @@ "BUZZ_ACP_LAZY_POOL": "true", "BUZZ_ACP_MODEL": "gpt-5", "BUZZ_ACP_RELAY_OBSERVER": "true", + "BUZZ_ACP_SESSION_POLICY": "channel", "BUZZ_ACP_SESSION_TITLE": "worker", "GOOSE_MODE": "auto" } diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 8f8db4d2893..ef9ce7c7921 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -53,6 +53,17 @@ buzz channels topic --channel --topic "New topic" buzz reactions add --event --emoji "👍" buzz reactions get --event +# GIFs (requires relay to advertise buzz-gif / KLIPY) +buzz gifs search # trending GIFs +buzz gifs search --query "celebration" # search GIFs +buzz gifs share --slug # report selection to provider Recents +# Paste the `cdn_url` from a search result directly into messages send --content + +# Custom emoji in messages +# buzz messages send scans outgoing content for :shortcode: patterns and +# automatically attaches NIP-30 ["emoji", shortcode, url] tags from the +# workspace palette — identical to the desktop composer behavior. + # Users & Presence buzz users get # your own profile buzz users get --pubkey # single user @@ -130,6 +141,8 @@ stored rules in `validation_error` so an owner can remove and repair them. | `reactions` | `add` | React to a message | | | `remove` | Remove a reaction | | | `get` | List reactions | +| `gifs` | `search` | Search or browse trending GIFs (requires relay buzz-gif support) | +| | `share` | Report a selected GIF to the provider's Recents | | `dms` | `list` | List DM conversations | | | `open` | Open a DM (1–8 pubkeys) | | | `add-member` | Add member to DM group | diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 76d0e6fb959..75c87aa427f 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -870,6 +870,47 @@ impl BuzzClient { .await } + /// POST a JSON body to a relay-relative path with NIP-98 authentication. + /// + /// Used by `buzz gifs search` and `buzz gifs share` to reach the relay's + /// KLIPY proxy endpoints. Returns the raw response body as a string (may + /// be empty for 204 No Content responses). + pub async fn post_json_authed( + &self, + path: &str, + body: &serde_json::Value, + ) -> Result { + let url = format!("{}{path}", self.relay_url); + let body_bytes = bytes::Bytes::from( + serde_json::to_vec(body) + .map_err(|e| CliError::Other(format!("request serialization failed: {e}")))?, + ); + self.with_retry_body(|| { + let body_bytes = body_bytes.clone(); + let url = url.clone(); + async move { + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; + let resp = self + .with_auth_tag( + self.http + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body_bytes), + ) + .send() + .await?; + // 204 No Content: return empty string rather than failing on + // an empty body that cannot be parsed as JSON. + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(String::new()); + } + self.handle_response(resp).await + } + }) + .await + } + /// Submit a signed Nostr event via POST /events. /// /// For non-idempotent moderation command kinds (9040–9044), an ambiguous diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index d5dbff3f5cb..32aef2f9273 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -16,10 +16,22 @@ struct EmojiEntry { } /// Parse `["emoji", shortcode, url]` tags from one event into entries. +/// +/// Mirrors desktop `customEmojiFromTags` (`desktop/src/shared/api/customEmoji.ts`): +/// - Shortcode is canonicalized via `buzz_sdk::normalize_custom_emoji_shortcode` +/// (trim whitespace/colons, validate charset/length, lowercase). The relay +/// validates with the same fn at ingest but stores the original signed tag, +/// so a relay-valid stored key like `" :WAVE: "` must be normalized here or +/// it will never resolve against `scan_shortcodes` output. Malformed tags +/// (where normalization returns `Err`) are skipped. +/// - Entries with a missing or empty URL are skipped. +/// - Within one event the first occurrence of a normalized shortcode wins; +/// later duplicates are dropped. fn emoji_tags_of(event: &serde_json::Value) -> Vec { let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else { return vec![]; }; + let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for tag in tags { let Some(parts) = tag.as_array() else { @@ -28,16 +40,33 @@ fn emoji_tags_of(event: &serde_json::Value) -> Vec { if parts.first().and_then(|v| v.as_str()) != Some("emoji") { continue; } - let (Some(shortcode), Some(url)) = ( + let (Some(raw_shortcode), Some(url)) = ( parts.get(1).and_then(|v| v.as_str()), parts.get(2).and_then(|v| v.as_str()), ) else { continue; }; - out.push(EmojiEntry { - shortcode: shortcode.to_string(), - url: url.to_string(), - }); + // Skip entries with empty URL — they are malformed and would silently + // produce tags without a resolvable image. + if url.is_empty() { + continue; + } + // Canonicalize via the SDK normalizer: trim whitespace/colons, validate + // charset/length, lowercase. Relay validates with this same fn at + // ingest but stores the original tag — so a relay-valid key like + // " :WAVE: " must map to "wave" here or it will never resolve against + // scan_shortcodes output. Skip on Err (malformed tag). + let shortcode = match buzz_sdk::normalize_custom_emoji_shortcode(raw_shortcode) { + Ok(s) => s, + Err(_) => continue, + }; + // First occurrence within this event wins; later duplicates are dropped. + if seen.insert(shortcode.clone()) { + out.push(EmojiEntry { + shortcode, + url: url.to_string(), + }); + } } out } @@ -308,6 +337,94 @@ async fn cmd_import( publish_own_set(client, &final_set).await } +/// Scan `content` for `:shortcode:` patterns, mirroring the desktop's +/// `customEmojiTags.ts` algorithm exactly: +/// +/// - Pattern: `:([a-z0-9_-]+):` (case-insensitive; canonical lowercase emitted) +/// - One tag per distinct first-appearing shortcode +/// - Unknown shortcodes silently ignored +/// +/// Returns NIP-30 `["emoji", shortcode, url]` tag vectors for every +/// shortcode that resolves in the workspace palette. Returns an empty `Vec` +/// without a relay round-trip if no candidates appear in the content. +/// +/// Callers must pre-screen with `content.contains(':')` to skip this +/// function entirely for the common case of plain content. +pub async fn resolve_emoji_tags_for_content( + client: &BuzzClient, + content: &str, +) -> Result>, CliError> { + let candidates = scan_shortcodes(content); + if candidates.is_empty() { + return Ok(Vec::new()); + } + + // Fetch workspace palette (union of all members' kind:30030 sets). + let filter = serde_json::json!({ + "kinds": [buzz_sdk::kind::KIND_EMOJI_SET], + "#d": [CUSTOM_EMOJI_SET_D_TAG], + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?; + let palette = union_custom_emoji(&events); + let url_by_shortcode: std::collections::HashMap<&str, &str> = palette + .iter() + .map(|e| (e.shortcode.as_str(), e.url.as_str())) + .collect(); + + let tags: Vec> = candidates + .iter() + .filter_map(|sc| { + url_by_shortcode + .get(sc.as_str()) + .map(|url| vec!["emoji".to_string(), sc.clone(), url.to_string()]) + }) + .collect(); + + Ok(tags) +} + +/// Collect candidate shortcodes from `content` without a regex dependency. +/// +/// Implements `:([a-z0-9_-]+):` (applied case-insensitively with lowercase +/// normalization) using a hand-rolled single-pass scanner. Each distinct +/// shortcode appears exactly once in first-appearance order. +pub(crate) fn scan_shortcodes(content: &str) -> Vec { + let bytes = content.as_bytes(); + let len = bytes.len(); + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + let mut i = 0; + while i < len { + if bytes[i] != b':' { + i += 1; + continue; + } + // Found opening `:`. Scan forward for valid shortcode chars. + let start = i + 1; + let mut j = start; + while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'-') + { + j += 1; + } + // Require at least one char and a closing `:`. + if j > start && j < len && bytes[j] == b':' { + // SAFETY: `content` is valid UTF-8 and the slice covers only ASCII. + let sc = content[start..j].to_lowercase(); + if seen.insert(sc.clone()) { + out.push(sc); + } + // Advance past the closing `:` so overlapping patterns like `:a::b:` + // are handled correctly (`:a:` consumed, next scan starts at `:`). + i = j + 1; + } else { + i += 1; + } + } + out +} + pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::EmojiCmd; match cmd { @@ -386,4 +503,308 @@ mod tests { assert_eq!(emojis[0].shortcode, "zort"); assert_eq!(emojis[0].url, "https://example.com/zort.png"); } + + // ── scan_shortcodes ────────────────────────────────────────────────────── + + // ── emoji_tags_of — normalization and dedup ────────────────────────────── + + #[test] + fn emoji_tags_of_normalizes_uppercase_shortcode_to_lowercase() { + // Relay stores the original case; scanner always lowercases; so a + // stored "WAVE" must map to "wave" for resolution to work. Also + // covers relay-valid keys with surrounding whitespace/colons. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "WAVE", "https://example.com/wave.png"], + ["emoji", " :SweatBlob: ", "https://example.com/sweatblob.gif"], + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].shortcode, "wave"); + assert_eq!(entries[1].shortcode, "sweatblob"); + } + + #[test] + fn emoji_tags_of_skips_empty_url() { + // An entry with a missing or empty URL is malformed; it must be + // dropped so palette lookups never return an unusable image URL. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "good", "https://example.com/good.png"], + ["emoji", "bad", ""], + ["emoji", "alsobad"], // missing url field entirely + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].shortcode, "good"); + } + + #[test] + fn emoji_tags_of_first_occurrence_wins_within_event() { + // Within one event the first occurrence of a (normalized) shortcode + // wins; a later duplicate tag for the same shortcode is dropped. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "wave", "https://example.com/wave-first.png"], + ["emoji", "wave", "https://example.com/wave-second.png"], + ["emoji", "WAVE", "https://example.com/wave-uppercase.png"], + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!( + entries.len(), + 1, + "all three normalize to 'wave'; only first kept" + ); + assert_eq!(entries[0].url, "https://example.com/wave-first.png"); + } + + #[test] + fn scan_finds_basic_shortcode() { + assert_eq!(scan_shortcodes(":wave:"), vec!["wave"]); + } + + #[test] + fn scan_finds_multiple_shortcodes_in_order() { + let result = scan_shortcodes(":wave: hello :party_parrot: world :tada:"); + assert_eq!(result, vec!["wave", "party_parrot", "tada"]); + } + + #[test] + fn scan_deduplicates_shortcodes() { + let result = scan_shortcodes(":wave: :wave: :wave:"); + assert_eq!(result, vec!["wave"]); + } + + #[test] + fn scan_normalizes_to_lowercase() { + let result = scan_shortcodes(":WAVE: :Wave:"); + assert_eq!(result, vec!["wave"]); + } + + #[test] + fn scan_ignores_invalid_chars_in_shortcode() { + // Spaces inside are not valid shortcode chars + let result = scan_shortcodes(":hello world:"); + assert!(result.is_empty()); + } + + #[test] + fn scan_empty_colons_not_matched() { + // "::" has zero chars between — must not match + assert!(scan_shortcodes("::").is_empty()); + } + + #[test] + fn scan_no_candidates_in_plain_content() { + assert!(scan_shortcodes("Hello world, no emoji here").is_empty()); + } + + #[test] + fn scan_handles_adjacent_shortcodes() { + // ":a::b:" — `:a:` consumed, then `:b:` starts at `:` + let result = scan_shortcodes(":a::b:"); + assert_eq!(result, vec!["a", "b"]); + } + + #[test] + fn scan_allows_hyphens_and_underscores() { + let result = scan_shortcodes(":party-parrot: :sweat_blob:"); + assert_eq!(result, vec!["party-parrot", "sweat_blob"]); + } + + // ── resolve_emoji_tags_for_content — send-path palette seam ───────────── + // + // These tests drive the production `resolve_emoji_tags_for_content` through + // a real `BuzzClient` against an axum fake `/query` server. They verify + // the full chain: scan → palette fetch → tag assembly. + + use crate::client::BuzzClient; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode}; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + fn test_client(base_url: &str) -> BuzzClient { + BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + } + + /// Fake relay: serves a `/query` endpoint returning the given JSON body, + /// and records how many times it was called. + async fn fake_query_server(response_body: String) -> (String, Arc>) { + let call_count: Arc> = Arc::new(Mutex::new(0)); + type S = (Arc>, String); + let state: S = (call_count.clone(), response_body); + + let app = Router::new() + .route( + "/query", + post( + |State((count, body)): State, _headers: HeaderMap, _req: Bytes| async move { + *count.lock().unwrap() += 1; + (StatusCode::OK, [("content-type", "application/json")], body) + }, + ), + ) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), call_count) + } + + /// Palette response: two custom emoji — `wave` and `sweatblob`. + fn palette_response() -> String { + serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + ["emoji", "wave", "https://cdn.example.com/wave.png"], + ["emoji", "sweatblob", "https://cdn.example.com/sweatblob.gif"] + ] + }]) + .to_string() + } + + #[tokio::test] + async fn resolve_tags_known_shortcode_returns_correct_tag() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + let tags = resolve_emoji_tags_for_content(&client, "hello :wave:") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!( + tags[0], + vec!["emoji", "wave", "https://cdn.example.com/wave.png"] + ); + } + + #[tokio::test] + async fn resolve_tags_unknown_shortcode_is_filtered_out() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // :notarealemoji: is not in the palette — must produce no tags. + let tags = resolve_emoji_tags_for_content(&client, ":notarealemoji:") + .await + .unwrap(); + assert!(tags.is_empty()); + } + + #[tokio::test] + async fn resolve_tags_deduplicates_repeated_shortcode() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:wave:` appears twice; output must have exactly one tag for it. + let tags = resolve_emoji_tags_for_content(&client, ":wave: and :wave: again") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0][1], "wave"); + } + + #[tokio::test] + async fn resolve_tags_first_appearance_order() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:sweatblob:` before `:wave:` — tags must appear in that order. + let tags = resolve_emoji_tags_for_content(&client, ":sweatblob: :wave:") + .await + .unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0][1], "sweatblob"); + assert_eq!(tags[1][1], "wave"); + } + + #[tokio::test] + async fn resolve_tags_case_insensitive_match_emits_lowercase() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:WAVE:` must resolve to the lowercase `wave` tag. + let tags = resolve_emoji_tags_for_content(&client, ":WAVE:") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!( + tags[0][1], "wave", + "canonical tag shortcode must be lowercase" + ); + } + + #[tokio::test] + async fn resolve_tags_no_colon_content_skips_palette_query() { + let (url, call_count) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // Content with no `:` must return empty tags with ZERO relay queries. + let tags = resolve_emoji_tags_for_content(&client, "Hello world, no colons here") + .await + .unwrap(); + assert!(tags.is_empty()); + assert_eq!( + *call_count.lock().unwrap(), + 0, + "must not query the palette when content has no colon" + ); + } + + #[tokio::test] + async fn resolve_tags_unknown_only_content_still_queries_once() { + let (url, call_count) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // Content has `:` but the shortcode is not in the palette. + // One palette query should occur (candidates are non-empty), zero tags returned. + let tags = resolve_emoji_tags_for_content(&client, ":notreal:") + .await + .unwrap(); + assert!(tags.is_empty()); + assert_eq!( + *call_count.lock().unwrap(), + 1, + "must query palette once even when no shortcodes resolve" + ); + } + + #[tokio::test] + async fn resolve_tags_non_canonical_palette_key_resolves() { + // The relay validates shortcodes via normalize_custom_emoji_shortcode but + // stores the original signed tag. A relay-valid stored key like + // " :WAVE: " must resolve when content contains `:wave:`. + // This is the production-resolver regression that proves emoji_tags_of + // uses the SDK normalizer rather than a plain lowercase conversion. + let non_canonical_palette = serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + // Relay-valid but non-canonical: whitespace + surrounding colons + uppercase. + ["emoji", " :WAVE: ", "https://cdn.example.com/wave.png"], + ] + }]) + .to_string(); + let (url, _calls) = fake_query_server(non_canonical_palette).await; + let client = test_client(&url); + let tags = resolve_emoji_tags_for_content(&client, "hello :wave:") + .await + .unwrap(); + assert_eq!( + tags.len(), + 1, + "non-canonical palette key must resolve; got tags: {tags:?}" + ); + assert_eq!( + tags[0], + vec!["emoji", "wave", "https://cdn.example.com/wave.png"], + "resolved tag must use the canonical lowercase shortcode" + ); + } } diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs new file mode 100644 index 00000000000..0352d2cdb37 --- /dev/null +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -0,0 +1,1035 @@ +//! Agent GIF search and share via the relay's KLIPY proxy. +//! +//! `buzz gifs search` / `buzz gifs share` hit the relay-relative endpoints +//! advertised in the NIP-11 `gif` descriptor. No provider credential is held +//! by the agent — the relay proxies KLIPY and returns only allowlisted data. +//! +//! Sending a GIF is a normal message whose content contains the `cdn_url` +//! returned by search — no special send-path handling, no imeta. + +use crate::client::BuzzClient; +use crate::error::CliError; + +/// Gate: `supported_extensions` must contain this value. +const REQUIRED_EXTENSION: &str = "buzz-gif"; +/// Gate: `gif.provider` must be this value. +const REQUIRED_PROVIDER: &str = "klipy"; + +// --------------------------------------------------------------------------- +// Safe relay-relative path validation +// --------------------------------------------------------------------------- + +/// Validate that a NIP-11-advertised path is a safe relay-relative path. +/// +/// Mirrors the desktop `safeRelayPath` contract in +/// `desktop/src/features/gifs/api.ts:64-74` exactly: +/// - must be a string that starts with `/` +/// - must NOT start with `//` (avoids authority shift) +/// - must NOT contain `\` (Windows-style traversal) +/// - must NOT contain `%` (URL-encoded bypass attempts) +/// - must NOT contain `?` (query injection) +/// - must NOT contain `#` (fragment injection) +/// - no path segment may be `.` or `..` (traversal) +pub(crate) fn safe_relay_path(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.contains('\\') + && !path.contains('%') + && !path.contains('?') + && !path.contains('#') + && !path.split('/').any(|seg| seg == "." || seg == "..") +} + +// --------------------------------------------------------------------------- +// Customer ID derivation +// --------------------------------------------------------------------------- + +/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material. +/// +/// KLIPY requires a per-installation identifier that is stable and anonymous. +/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the +/// input is public, so the ID is computable by any observer, and the same value +/// would appear across all relays (cross-relay linkability). +/// +/// Instead, we domain-separate with the relay URL and sign with the *secret* key: +/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)` +/// This is: +/// - **stable**: deterministic given the same keypair + relay. +/// - **relay-scoped**: different relay → different ID, no cross-relay correlation. +/// - **not computable from public data**: requires secret key material. +/// - **stateless**: no file I/O, no storage. +/// +/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for +/// KLIPY's per-installation needs. +fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(secret_key_bytes); + hasher.update(b"\0"); // domain separator + hasher.update(relay_url.as_bytes()); + let hash = hasher.finalize(); + hex::encode(&hash[..16]) // 16 bytes → 32 hex chars +} + +// --------------------------------------------------------------------------- +// Locale +// --------------------------------------------------------------------------- + +/// Locale to send to KLIPY. Reads `LANG` first, falls back to `en_US`. +fn default_locale() -> String { + std::env::var("LANG") + .ok() + .and_then(|l| { + let code: String = l.split('.').next().unwrap_or("").chars().take(5).collect(); + if code.len() >= 2 { + Some(code) + } else { + None + } + }) + .unwrap_or_else(|| "en_US".to_string()) +} + +// --------------------------------------------------------------------------- +// NIP-11 descriptor resolution +// --------------------------------------------------------------------------- + +/// Parse the `gif` descriptor from a decoded NIP-11 JSON document. +/// +/// Shared between `resolve_gif_descriptor` (which fetches the document) and +/// tests (which inject a synthetic document directly). Separating the pure +/// parse logic from the I/O call makes the descriptor gates directly testable +/// without a fake HTTP server. +pub(crate) fn parse_gif_descriptor_info( + info: &serde_json::Value, +) -> Result<(String, String), CliError> { + // Gate 1: `supported_extensions` must contain `"buzz-gif"`. + let extensions = info + .get("supported_extensions") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + if !extensions.contains(&REQUIRED_EXTENSION) { + return Err(CliError::Other(format!( + "this relay does not support GIF search (missing \"{REQUIRED_EXTENSION}\" in supported_extensions)" + ))); + } + + // Gate 2: `gif.provider` must be `"klipy"`. + let gif = info.get("gif").ok_or_else(|| { + CliError::Other("relay advertises buzz-gif but has no \"gif\" descriptor".to_string()) + })?; + let provider = gif.get("provider").and_then(|v| v.as_str()).unwrap_or(""); + if provider != REQUIRED_PROVIDER { + return Err(CliError::Other(format!( + "unsupported GIF provider \"{provider}\" (only \"{REQUIRED_PROVIDER}\" is supported)" + ))); + } + + // Gate 3: both paths must be present and pass the safe-path check. + let search = gif + .get("search") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let share = gif + .get("share") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if !safe_relay_path(&search) { + return Err(CliError::Other(format!( + "relay gif descriptor search path is not a safe relay-relative path: {search:?}" + ))); + } + if !safe_relay_path(&share) { + return Err(CliError::Other(format!( + "relay gif descriptor share path is not a safe relay-relative path: {share:?}" + ))); + } + + Ok((search, share)) +} + +/// Resolve the relay's `gif` descriptor from its NIP-11 document. +/// +/// Returns `(search_path, share_path)` as validated relay-relative strings. +/// Fails with a clear `CliError` if: +/// - the relay does not advertise `buzz-gif` +/// - the provider is not `klipy` +/// - either path is absent or fails the `safe_relay_path` check +pub(crate) async fn resolve_gif_descriptor( + client: &BuzzClient, +) -> Result<(String, String), CliError> { + let raw = client.get_public("/info").await?; + let info: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?; + parse_gif_descriptor_info(&info) +} + +// --------------------------------------------------------------------------- +// Response normalization +// --------------------------------------------------------------------------- + +/// Normalized GIF entry emitted by `buzz gifs search`. +/// +/// `cdn_url` is the URL to embed directly in a `buzz messages send --content` +/// argument. Agents paste it as-is; no further processing is needed. +#[derive(serde::Serialize)] +pub(crate) struct GifEntry { + pub cdn_url: String, + pub slug: String, + pub title: String, + pub width: u64, + pub height: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_url: Option, +} + +/// Normalize the KLIPY `data.data` array to typed `GifEntry` records. +/// +/// Mirrors `normalizeKlipyGifs` in `desktop/src/features/gifs/api.ts`: +/// - skips items that are not `type: "gif"`, lack a `slug`, or have no +/// complete sendable asset +/// - asset fallback order for `cdn_url` (original): `md.gif`, `hd.gif`, +/// `sm.gif`, `xs.gif` +/// - asset fallback order for `preview_url`: `sm.webp`, `sm.gif`, +/// `xs.webp`, `xs.gif`, `md.webp` +/// - an item with no usable original or preview is silently skipped +/// - malformed envelopes (wrong outer shape) return an error rather +/// than a silent empty array +pub(crate) fn normalize_gif_response(raw: &str) -> Result, CliError> { + let parsed: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("invalid GIF search response: {e}")))?; + + // The relay wraps in {"result": true, "data": {"data": [...]}}. + // A missing outer envelope is an error, not a silent empty list. + let items = parsed + .get("data") + .and_then(|d| d.get("data")) + .and_then(|v| v.as_array()) + .ok_or_else(|| { + CliError::Other( + "GIF search response missing expected envelope data.data array".to_string(), + ) + })?; + + let mut out = Vec::new(); + for item in items { + // Only process type:"gif" items with a slug. + if item.get("type").and_then(|v| v.as_str()) != Some("gif") { + continue; + } + let slug = match item.get("slug").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + let title = item + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "GIF".to_string()); + + let file = match item.get("file") { + Some(f) => f, + None => continue, + }; + + // cdn_url: md.gif → hd.gif → sm.gif → xs.gif + let original = first_complete_gif_asset( + file, + &[ + &["md", "gif"], + &["hd", "gif"], + &["sm", "gif"], + &["xs", "gif"], + ], + ); + // preview_url: sm.webp → sm.gif → xs.webp → xs.gif → md.webp + let preview = first_complete_gif_asset( + file, + &[ + &["sm", "webp"], + &["sm", "gif"], + &["xs", "webp"], + &["xs", "gif"], + &["md", "webp"], + ], + ); + + let (cdn_url, width, height) = match original { + Some(a) => a, + None => continue, + }; + + let preview_url = preview.map(|(u, _, _)| u); + + out.push(GifEntry { + cdn_url, + slug, + title, + width, + height, + preview_url, + }); + } + + Ok(out) +} + +/// Extract the URL, width, and height from the first complete asset at +/// `file[size][fmt]` where `size`/`fmt` pairs are tried in order. +/// "Complete" means url (non-empty string), width (number), height (number) +/// are all present — mirrors `isCompleteAsset` in the desktop. +fn first_complete_gif_asset( + file: &serde_json::Value, + candidates: &[&[&str; 2]], +) -> Option<(String, u64, u64)> { + for &[size, fmt] in candidates { + let asset = file.get(size).and_then(|s| s.get(fmt)); + if let Some(a) = asset { + let url = a.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let width = a.get("width").and_then(|v| v.as_u64()); + let height = a.get("height").and_then(|v| v.as_u64()); + if !url.is_empty() { + if let (Some(w), Some(h)) = (width, height) { + return Some((url.to_string(), w, h)); + } + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// `buzz gifs search [--query ] [--locale ]` +/// +/// Empty/omitted `query` returns KLIPY trending GIFs. Output is a JSON array +/// of normalized GIF objects; each entry's `cdn_url` is the URL to embed in a +/// `buzz messages send --content` argument. +pub async fn cmd_search( + client: &BuzzClient, + query: &str, + locale: Option<&str>, +) -> Result<(), CliError> { + let entries = search_entries(client, query, locale).await?; + println!( + "{}", + serde_json::to_string(&entries) + .map_err(|e| CliError::Other(format!("output serialization failed: {e}")))? + ); + Ok(()) +} + +/// Resolve NIP-11, POST the search, normalize and return typed GIF entries. +/// +/// Extracted from `cmd_search` so tests can assert the typed result directly +/// without capturing stdout. +pub(crate) async fn search_entries( + client: &BuzzClient, + query: &str, + locale: Option<&str>, +) -> Result, CliError> { + let (search_path, _) = resolve_gif_descriptor(client).await?; + let cid = customer_id( + client.keys().secret_key().as_secret_bytes(), + client.relay_url(), + ); + let locale = locale.map(|l| l.to_string()).unwrap_or_else(default_locale); + + let body = serde_json::json!({ + "query": query, + "customer_id": cid, + "locale": locale, + }); + let raw = client.post_json_authed(&search_path, &body).await?; + normalize_gif_response(&raw) +} + +/// `buzz gifs share --slug ` +/// +/// Reports a selected GIF to KLIPY so it can update Recents. The `slug` is +/// the provider identifier returned in search results. Prints +/// `{"accepted": true}` on success. +pub async fn cmd_share(client: &BuzzClient, slug: &str) -> Result<(), CliError> { + let (_, share_path) = resolve_gif_descriptor(client).await?; + let cid = customer_id( + client.keys().secret_key().as_secret_bytes(), + client.relay_url(), + ); + + let body = serde_json::json!({ + "slug": slug, + "customer_id": cid, + }); + // The relay returns 204 No Content on success; post_json_authed returns "". + client.post_json_authed(&share_path, &body).await?; + println!("{}", serde_json::json!({"accepted": true})); + Ok(()) +} + +pub async fn dispatch(cmd: crate::GifsCmd, client: &BuzzClient) -> Result<(), CliError> { + match cmd { + crate::GifsCmd::Search { query, locale } => { + cmd_search(client, query.as_deref().unwrap_or(""), locale.as_deref()).await + } + crate::GifsCmd::Share { slug } => cmd_share(client, &slug).await, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // safe_relay_path + // ----------------------------------------------------------------------- + + #[test] + fn safe_relay_path_accepts_normal_paths() { + assert!(safe_relay_path("/gifs/search")); + assert!(safe_relay_path("/gifs/share")); + assert!(safe_relay_path("/api/v2/gifs/search")); + } + + #[test] + fn safe_relay_path_rejects_adversarial_corpus() { + // Desktop adversarial corpus from desktop/src/features/gifs/api.test.mjs + let bad_paths = [ + "https://attacker.example/search", // absolute URL, no leading / + "//attacker.example/search", // protocol-relative → authority shift + "/\\attacker.example/search", // backslash + "/%5c%5cattacker.example/search", // percent-encoded + "/gifs/../admin", // dot-dot traversal + "/gifs/%2e%2e/admin", // percent-encoded dot-dot + "/gifs/search?redirect=https://attacker.example", // query injection + "/gifs/search#fragment", // fragment injection + ]; + for path in bad_paths { + assert!( + !safe_relay_path(path), + "expected safe_relay_path({path:?}) == false" + ); + } + } + + #[test] + fn safe_relay_path_rejects_empty_and_relative() { + assert!(!safe_relay_path("")); + assert!(!safe_relay_path("gifs/search")); // no leading / + assert!(!safe_relay_path("//")); + } + + // ----------------------------------------------------------------------- + // customer_id + // ----------------------------------------------------------------------- + + #[test] + fn customer_id_is_32_hex_chars_and_stable() { + let sk = [0xab_u8; 32]; + let id = customer_id(&sk, "https://relay.example"); + assert_eq!(id.len(), 32); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(id, customer_id(&sk, "https://relay.example")); + } + + #[test] + fn customer_id_is_relay_scoped() { + let sk = [0xcd_u8; 32]; + let id_a = customer_id(&sk, "https://relay-a.example"); + let id_b = customer_id(&sk, "https://relay-b.example"); + assert_ne!( + id_a, id_b, + "same key, different relay → different customer_id" + ); + } + + #[test] + fn customer_id_differs_for_different_keys() { + let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example"); + let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example"); + assert_ne!(id_a, id_b); + } + + #[test] + fn customer_id_not_equal_to_pubkey_hash() { + // The customer_id must NOT be derivable from the public key alone. + use sha2::{Digest, Sha256}; + let sk = [0xde_u8; 32]; + // What the old pubkey-hash approach would have produced (approximately): + let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]); + let actual = customer_id(&sk, "https://relay.example"); + assert_ne!( + actual, naive_hash, + "customer_id must not equal SHA-256(pubkey_hex)[..16]" + ); + } + + // ----------------------------------------------------------------------- + // default_locale + // ----------------------------------------------------------------------- + + #[test] + fn default_locale_is_nonempty() { + let locale = default_locale(); + assert!(!locale.is_empty()); + } + + // ----------------------------------------------------------------------- + // parse_gif_descriptor_info — production gate logic, no I/O + // ----------------------------------------------------------------------- + + #[test] + fn descriptor_missing_extension_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-emoji"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("buzz-gif"), + "error must mention buzz-gif, got: {err}" + ); + } + + #[test] + fn descriptor_wrong_provider_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "tenor", "search": "/gifs/search", "share": "/gifs/share" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("tenor"), + "error must mention the bad provider, got: {err}" + ); + } + + #[test] + fn descriptor_unsafe_search_path_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "//attacker.example/x", "share": "/gifs/share" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("search path"), + "error must mention search path, got: {err}" + ); + } + + #[test] + fn descriptor_unsafe_share_path_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/../admin" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("share path"), + "error must mention share path, got: {err}" + ); + } + + #[test] + fn descriptor_valid_passes() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } + }); + let (search, share) = parse_gif_descriptor_info(&info).unwrap(); + assert_eq!(search, "/gifs/search"); + assert_eq!(share, "/gifs/share"); + } + + // ----------------------------------------------------------------------- + // normalize_gif_response + // ----------------------------------------------------------------------- + + /// Fixture matching the shape used in desktop/tests/e2e/messaging.spec.ts + fn e2e_fixture() -> &'static str { + r#"{ + "result": true, + "data": { + "data": [ + { + "id": null, + "type": "gif", + "slug": "e2e-ship-it", + "title": "Ship it", + "file": { + "md": { "gif": { "height": 180, "size": 42, "url": "https://static.klipy.com/ship-it.gif", "width": 320 } }, + "sm": { "webp": { "height": 90, "size": 12, "url": "https://static.klipy.com/ship-it-sm.webp", "width": 160 } } + } + } + ] + } + }"# + } + + #[test] + fn normalize_extracts_cdn_url_and_preview() { + let entries = normalize_gif_response(e2e_fixture()).unwrap(); + assert_eq!(entries.len(), 1); + let e = &entries[0]; + assert_eq!(e.cdn_url, "https://static.klipy.com/ship-it.gif"); + assert_eq!(e.slug, "e2e-ship-it"); + assert_eq!(e.title, "Ship it"); + assert_eq!(e.width, 320); + assert_eq!(e.height, 180); + assert_eq!( + e.preview_url.as_deref(), + Some("https://static.klipy.com/ship-it-sm.webp") + ); + } + + #[test] + fn normalize_skips_non_gif_type() { + let raw = r#"{"result":true,"data":{"data":[ + {"type":"ad","slug":"s","file":{"md":{"gif":{"url":"https://x.com/a.gif","width":1,"height":1,"size":1}}}}, + {"type":"gif","slug":"real","title":"R","file":{"md":{"gif":{"url":"https://x.com/r.gif","width":2,"height":2,"size":2}}}} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "real"); + } + + #[test] + fn normalize_skips_items_without_slug() { + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","file":{"md":{"gif":{"url":"https://x.com/a.gif","width":1,"height":1,"size":1}}}} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn normalize_asset_fallback_order() { + // No md.gif, has hd.gif — should pick hd.gif as cdn_url. + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"fallback","title":"F","file":{ + "hd":{"gif":{"url":"https://x.com/hd.gif","width":640,"height":360,"size":100}}, + "sm":{"webp":{"url":"https://x.com/sm.webp","width":160,"height":90,"size":10}} + }} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].cdn_url, "https://x.com/hd.gif"); + } + + #[test] + fn normalize_skips_items_with_no_usable_original() { + // Only a preview asset, no gif asset at any size. + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"broken","title":"B","file":{ + "sm":{"webp":{"url":"https://x.com/sm.webp","width":160,"height":90,"size":10}} + }} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn normalize_rejects_malformed_envelope() { + // Missing the data.data wrapper — must error, not silently return []. + let bad = r#"{"result":true,"gifs":[]}"#; + assert!(normalize_gif_response(bad).is_err()); + } + + #[test] + fn normalize_empty_data_array_is_ok() { + let raw = r#"{"result":true,"data":{"data":[]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + // ----------------------------------------------------------------------- + // HTTP integration tests: real client seam via axum fake server + // ----------------------------------------------------------------------- + + use crate::client::BuzzClient; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode}; + use axum::routing::post; + use axum::Router; + use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; + use nostr::{JsonUtil, Keys, Tag}; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + /// Captured request data from the fake server. + #[derive(Clone, Default)] + struct Captured { + path: String, + auth_header: String, + auth_tag_header: String, + body: String, + } + + /// NIP-11 JSON that advertises non-default search/share paths. + /// + /// Production code must read the advertised paths from NIP-11 and POST to + /// them. Using non-default paths here means hardcoded "/gifs/search" / + /// "/gifs/share" in production would target 404 routes and the tests would + /// fail — proving that the relay-advertised path is actually used. + const ALT_SEARCH_PATH: &str = "/x/search-alt"; + const ALT_SHARE_PATH: &str = "/x/share-alt"; + + fn alt_nip11() -> &'static str { + // Embedded as a literal so there is no run-time allocation in the const. + r#"{"supported_extensions":["buzz-gif"],"gif":{"provider":"klipy","search":"/x/search-alt","share":"/x/share-alt"}}"# + } + + /// A simple fake relay: serves NIP-11 at `/info` advertising non-default + /// paths, then captures POST bodies at those paths. + async fn fake_server( + search_status: StatusCode, + search_body: String, + share_status: StatusCode, + ) -> (String, Arc>>) { + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + + type S = (Arc>>, StatusCode, String, StatusCode); + let state: S = (captured.clone(), search_status, search_body, share_status); + + let app = + Router::new() + .route( + "/info", + axum::routing::get(|| async { + ( + StatusCode::OK, + [("content-type", "application/nostr+json")], + alt_nip11(), + ) + }), + ) + .route( + ALT_SEARCH_PATH, + post( + |State((cap, search_st, search_bd, _)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + path: ALT_SEARCH_PATH.to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(search_st) + .header("content-type", "application/json") + .body(axum::body::Body::from(search_bd.clone())) + .unwrap() + }, + ), + ) + .route( + ALT_SHARE_PATH, + post( + |State((cap, _, _, share_st)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + path: ALT_SHARE_PATH.to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(share_st) + .body(axum::body::Body::empty()) + .unwrap() + }, + ), + ) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), captured) + } + + /// Client without an auth tag — used for basic NIP-98 / body / path tests. + fn test_client(base_url: &str) -> BuzzClient { + let keys = Keys::generate(); + BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + } + + /// Client with a synthetic `x-auth-tag` — used to assert that the header + /// is forwarded verbatim and that its value is the raw JSON of the tag. + fn test_client_with_tag(base_url: &str) -> (BuzzClient, String) { + let keys = Keys::generate(); + // Construct a minimal auth tag: ["auth", , "conditions", ] + let owner_hex = "a".repeat(64); + let sig_hex = "b".repeat(128); + let tag_vec = vec![ + "auth".to_string(), + owner_hex, + "conditions".to_string(), + sig_hex, + ]; + let tag_json = serde_json::to_string(&tag_vec).unwrap(); + let tag = Tag::parse(tag_vec).unwrap(); + let client = BuzzClient::new( + base_url.to_string(), + keys, + Some(tag), + Some(tag_json.clone()), + ) + .unwrap(); + (client, tag_json) + } + + fn one_gif_response() -> String { + serde_json::json!({"result":true,"data":{"data":[ + {"type":"gif","slug":"test-slug","title":"Test","file":{ + "md":{"gif":{"url":"https://cdn.klipy.com/test.gif","width":320,"height":180,"size":50}} + }} + ]}}) + .to_string() + } + + // ── item 1: relay-advertised path binding ────────────────────────────── + + #[tokio::test] + async fn search_posts_to_relay_advertised_path_not_hardcoded() { + // Fake advertises ALT_SEARCH_PATH; hardcoded "/gifs/search" would 404. + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_search(&client, "hello", Some("en_US")).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("POST must arrive at the NIP-11-advertised path"); + assert!( + call.auth_header.starts_with("Nostr "), + "Authorization must be a NIP-98 Nostr token, got: {:?}", + call.auth_header + ); + let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); + assert_eq!(body["query"], "hello"); + assert_eq!(body["locale"], "en_US"); + assert!( + body["customer_id"] + .as_str() + .map(|s| s.len() == 32) + .unwrap_or(false), + "customer_id must be 32 hex chars" + ); + } + + #[tokio::test] + async fn share_posts_to_relay_advertised_path_not_hardcoded() { + // Fake advertises ALT_SHARE_PATH; hardcoded "/gifs/share" would 404. + let (url, captured) = + fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_share(&client, "my-gif-slug").await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SHARE_PATH) + .expect("POST must arrive at the NIP-11-advertised share path"); + assert!( + call.auth_header.starts_with("Nostr "), + "Authorization must be a NIP-98 Nostr token" + ); + let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); + assert_eq!(body["slug"], "my-gif-slug"); + assert!( + body["customer_id"] + .as_str() + .map(|s| s.len() == 32) + .unwrap_or(false), + "customer_id must be 32 hex chars" + ); + } + + // ── item 2: x-auth-tag forwarded + NIP-98 deep assertions ───────────── + + #[tokio::test] + async fn search_forwards_x_auth_tag_header() { + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let (client, expected_tag_json) = test_client_with_tag(&url); + + cmd_search(&client, "", None).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("search POST must arrive"); + assert_eq!( + call.auth_tag_header, expected_tag_json, + "x-auth-tag must equal the exact JSON of the auth tag" + ); + } + + #[tokio::test] + async fn search_nip98_token_has_correct_u_method_and_payload_hash() { + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_search(&client, "cats", Some("en_US")).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("search POST must arrive"); + + // Decode "Nostr " → JSON event + let token = call + .auth_header + .strip_prefix("Nostr ") + .expect("must start with Nostr "); + let json_bytes = B64.decode(token).expect("must be valid base64"); + let event: nostr::Event = + nostr::Event::from_json(std::str::from_utf8(&json_bytes).unwrap()).unwrap(); + + // kind:27235 (NIP-98) + assert_eq!(event.kind.as_u16(), 27235); + + // `u` tag must be the exact POST URL + let expected_url = format!("{url}{ALT_SEARCH_PATH}"); + let u_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("u")) + .expect("NIP-98 event must have a u tag"); + assert_eq!( + u_tag.as_slice().get(1).map(|s| s.as_str()).unwrap_or(""), + expected_url + ); + + // `method` tag must be "POST" + let method_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("method")) + .expect("NIP-98 event must have a method tag"); + assert_eq!( + method_tag + .as_slice() + .get(1) + .map(|s| s.as_str()) + .unwrap_or(""), + "POST" + ); + + // `payload` tag must equal SHA-256 of the request body + use sha2::{Digest, Sha256}; + let body_bytes = call.body.as_bytes(); + let expected_hash = hex::encode(Sha256::digest(body_bytes)); + let payload_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("payload")) + .expect("NIP-98 event must have a payload tag for POST with body"); + assert_eq!( + payload_tag + .as_slice() + .get(1) + .map(|s| s.as_str()) + .unwrap_or(""), + expected_hash, + "payload tag must be SHA-256 of the request body" + ); + } + + // ── item 3: search_output_contains_cdn_url asserts typed result ──────── + + #[tokio::test] + async fn search_entries_returns_top_level_cdn_url() { + // Tests that cmd_search delegates to search_entries() which returns + // typed output with cdn_url at the top level. A raw-passthrough + // regression (no normalize_gif_response) would produce a different + // struct shape and cdn_url would be absent. + let (url, _) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + let entries = search_entries(&client, "", None).await.unwrap(); + + assert!(!entries.is_empty(), "must return at least one entry"); + assert_eq!( + entries[0].cdn_url, "https://cdn.klipy.com/test.gif", + "cdn_url must be the normalized top-level URL from md.gif" + ); + assert_eq!(entries[0].slug, "test-slug"); + } + + // ── existing negative gate ───────────────────────────────────────────── + + #[tokio::test] + async fn share_returns_accepted_true_on_204() { + let (url, _) = fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + cmd_share(&client, "slug-abc").await.unwrap(); + } + + #[tokio::test] + async fn search_rejects_missing_extension_in_nip11() { + // Serve NIP-11 without buzz-gif. + let app = Router::new().route( + "/info", + axum::routing::get(|| async { + ( + StatusCode::OK, + [("content-type", "application/nostr+json")], + r#"{"supported_extensions":[],"gif":{"provider":"klipy","search":"/x/search-alt","share":"/x/share-alt"}}"#, + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let url = format!("http://{addr}"); + let client = test_client(&url); + + let err = cmd_search(&client, "test", None).await.unwrap_err(); + assert!( + err.to_string().contains("buzz-gif"), + "error must mention buzz-gif, got: {err}" + ); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..f80f928d316 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -698,15 +698,43 @@ pub async fn cmd_send_message( ) .map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))? } - None | Some(9) => buzz_sdk::build_message( - channel_uuid, - &final_content, - thread_ref.as_ref(), - &mention_refs, - p.broadcast, - &media_tags, - ) - .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?, + None | Some(9) => { + // Scan final_content for `:shortcode:` patterns and attach NIP-30 + // emoji tags for any that resolve in the workspace palette. + // Palette resolution is scoped to kind 9: forum builders (45001, + // 45003) do not accept emoji_tags, so resolving early would pay + // the relay query and immediately discard the result. + // The fetch is skipped entirely when content has no `:`, keeping + // plain sends at zero extra RTTs. Palette resolution is + // decorative enrichment — a fetch or parse failure must not block + // delivery of a valid message; on error, degrade to no emoji tags + // and log a diagnostic to stderr. + let emoji_tags = if final_content.contains(':') { + match crate::commands::emoji::resolve_emoji_tags_for_content(client, &final_content) + .await + { + Ok(tags) => tags, + Err(e) => { + eprintln!( + "warning: emoji palette fetch failed ({e}); sending without emoji tags" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + buzz_sdk::build_message( + channel_uuid, + &final_content, + thread_ref.as_ref(), + &mention_refs, + p.broadcast, + &media_tags, + &emoji_tags, + ) + .map_err(|e| CliError::Other(format!("build_message failed: {e}")))? + } Some(k) => { return Err(CliError::Usage(format!( "--kind {k} is not supported (use 9, 45001, or 45003)" @@ -1056,11 +1084,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags, - format_events, match_profiles_by_name, merge_message_mentions, missing_members, - normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, - resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient, - CliError, Uuid, + channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys, + find_root_from_tags, format_events, match_profiles_by_name, merge_message_mentions, + missing_members, normalize_explicit_mentions, parse_member_pubkeys, + resolve_names_to_pubkeys, resolve_thread_target, thread_ref_from_event, + thread_ref_from_parent_tags, BuzzClient, CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1570,4 +1598,294 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + // ── cmd_send_message — emoji-tag binding seam ───────────────────────── + // + // These tests drive `cmd_send_message` through a minimal fake relay + // serving `/query` (emoji palette) and `/events` (event submission). + // + // Content with no `@` and no explicit mentions bypasses member-resolution + // relay calls, so the only relay traffic is: + // 1. POST /query — emoji palette fetch (when content has `:`) + // 2. POST /events — signed event submission + // + // Removing the resolver call at messages.rs:687-691 or passing &[] at + // :718 would cause the emoji-tag assertions below to fail. + + use axum::body::Bytes as AxumBytes; + use axum::extract::State as AxumState; + use axum::http::{HeaderMap as AxumHeaderMap, StatusCode as AxumStatusCode}; + use axum::routing::post as axum_post; + use axum::Router as AxumRouter; + use std::net::SocketAddr as StdSocketAddr; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc as StdArc; + use tokio::net::TcpListener as TokioTcpListener; + + /// Captured body of a POST /events call. + #[derive(Clone, Default)] + struct CapturedEvent { + body: String, + } + + /// Minimal fake relay for send-path tests. + /// + /// - `/query` returns the given `query_body` on every call and increments + /// `query_count`. + /// - `/events` returns `{"event_id":"fake","accepted":true}` and records + /// the raw event JSON in `captured_event`. + async fn fake_send_relay( + query_body: String, + ) -> ( + String, + StdArc, + StdArc>>, + ) { + let query_count = StdArc::new(AtomicU32::new(0)); + let captured_event: StdArc>> = + StdArc::new(std::sync::Mutex::new(None)); + + type S = ( + StdArc, + String, + StdArc>>, + ); + let state: S = (query_count.clone(), query_body, captured_event.clone()); + + let app = AxumRouter::new() + .route( + "/query", + axum_post( + |AxumState((count, body, _)): AxumState, + _headers: AxumHeaderMap, + _req: AxumBytes| async move { + count.fetch_add(1, Ordering::Relaxed); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + body, + ) + }, + ), + ) + .route( + "/events", + axum_post( + |AxumState((_, _, cap)): AxumState, + _headers: AxumHeaderMap, + body: AxumBytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + *cap.lock().unwrap() = Some(CapturedEvent { body: body_str }); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + r#"{"event_id":"fake0000","accepted":true}"#, + ) + }, + ), + ) + .with_state(state); + + let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: StdSocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), query_count, captured_event) + } + + /// Palette JSON with one emoji: `wave` → some URL. + fn send_palette_response() -> String { + serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + ["emoji", "wave", "https://cdn.example.com/wave.png"], + ["emoji", "sweatblob", "https://cdn.example.com/sweatblob.gif"] + ] + }]) + .to_string() + } + + /// A valid channel UUID used across send-path tests. + const SEND_TEST_CHANNEL: &str = "123e4567-e89b-12d3-a456-426614174000"; + + fn send_params(content: &str) -> super::SendMessageParams { + super::SendMessageParams { + channel_id: SEND_TEST_CHANNEL.to_string(), + content: content.to_string(), + kind: None, + reply_to: None, + broadcast: false, + files: vec![], + mentions: vec![], + } + } + + #[tokio::test] + async fn cmd_send_message_attaches_emoji_tags_for_known_shortcodes() { + // Content contains `:wave:` which resolves in the palette. + // The submitted event must carry an `emoji` tag for `wave`. + let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + cmd_send_message(&client, send_params("hello :wave: everyone")) + .await + .unwrap(); + + // Palette was queried at least once (short-circuit was NOT triggered). + assert!( + query_count.load(Ordering::Relaxed) >= 1, + "palette must be queried when content has a colon" + ); + + // Submitted event must contain an emoji tag for `wave`. + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags + .iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some("wave")), + "submitted event must have an emoji tag for `wave`, got tags: {tags:?}" + ); + // Unknown shortcodes must not produce tags. + assert!( + !emoji_tags + .iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some("notreal")), + "unknown shortcodes must not produce emoji tags" + ); + } + + #[tokio::test] + async fn cmd_send_message_skips_palette_query_when_no_colon_in_content() { + // Content has no `:` at all — the palette query must be skipped + // entirely (zero RTTs), and the submitted event must have no emoji tags. + let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + cmd_send_message(&client, send_params("plain message no colons")) + .await + .unwrap(); + + assert_eq!( + query_count.load(Ordering::Relaxed), + 0, + "palette must NOT be queried when content has no colon" + ); + + // Submitted event must have no emoji tags. + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags.is_empty(), + "no-colon content must produce no emoji tags, got: {emoji_tags:?}" + ); + } + + #[tokio::test] + async fn cmd_send_message_succeeds_when_palette_query_errors() { + // Palette enrichment is decorative — a 500 from the `/query` endpoint + // must not abort delivery; the message must still be sent with zero + // emoji tags, and a diagnostic must be emitted to stderr. + + // Fake relay: `/query` returns 500, `/events` accepts and captures. + let captured_event: StdArc>> = + StdArc::new(std::sync::Mutex::new(None)); + let cap = captured_event.clone(); + let app = AxumRouter::new() + .route( + "/query", + axum_post(|_headers: AxumHeaderMap, _req: AxumBytes| async move { + ( + AxumStatusCode::INTERNAL_SERVER_ERROR, + [("content-type", "application/json")], + r#"{"error":"unavailable"}"#, + ) + }), + ) + .route( + "/events", + axum_post(move |_headers: AxumHeaderMap, body: AxumBytes| { + let cap = cap.clone(); + async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + *cap.lock().unwrap() = Some(CapturedEvent { body: body_str }); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + r#"{"event_id":"fake0001","accepted":true}"#, + ) + } + }), + ); + + let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: StdSocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let url = format!("http://{addr}"); + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + // Must not return Err — a palette failure is a soft warning. + cmd_send_message(&client, send_params(":wave: message with emoji candidate")) + .await + .expect("send must succeed even when palette query returns 500"); + + // Submitted event must have zero emoji tags (fallback to empty). + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags.is_empty(), + "palette-error fallback must produce no emoji tags, got: {emoji_tags:?}" + ); + } } diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8bb24218eb5..7ed03f9d060 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod channels; pub mod dms; pub mod emoji; pub mod feed; +pub mod gifs; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa2..3f2bea73979 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -192,6 +192,9 @@ enum Cmd { /// Manage your custom emoji set (workspace palette is the union of all members' sets) #[command(subcommand)] Emoji(EmojiCmd), + /// Search and share GIFs via the relay's KLIPY proxy + #[command(subcommand)] + Gifs(GifsCmd), /// List, open, and manage direct messages #[command(subcommand)] Dms(DmsCmd), @@ -806,6 +809,31 @@ pub enum EmojiCmd { }, } +#[derive(Subcommand)] +pub enum GifsCmd { + /// Search or browse trending GIFs via the relay's KLIPY proxy. + /// + /// Omitting --query returns trending GIFs. The output is a JSON array of + /// GIF objects; paste the `cdn_url` field directly into + /// `buzz messages send --content` to share a GIF. + Search { + /// Search text; omit or leave empty for trending + #[arg(long)] + query: Option, + /// BCP 47 locale for provider results (default: $LANG or en_US) + #[arg(long)] + locale: Option, + }, + /// Report a selected GIF to the provider so it enters your Recents. + /// + /// The slug is the provider identifier in the search result objects. + Share { + /// Provider GIF slug from a search result + #[arg(long)] + slug: String, + }, +} + #[derive(Subcommand)] pub enum DmsCmd { /// List direct message conversations @@ -2080,6 +2108,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, + Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await, Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await, Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await, @@ -2229,6 +2258,7 @@ mod tests { "dms", "emoji", "feed", + "gifs", "issues", "media", "mem", diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4136072b70b..c1ed4833370 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -617,6 +617,8 @@ pub const KIND_HUDDLE_PARTICIPANT_JOINED: u32 = 48101; pub const KIND_HUDDLE_PARTICIPANT_LEFT: u32 = 48102; /// A huddle ended. pub const KIND_HUDDLE_ENDED: u32 = 48103; +/// Relay-synthesized authoritative liveness for an active huddle session. +pub const KIND_HUDDLE_LIVENESS: u32 = 48104; /// Huddle channel guidelines/rules document. pub const KIND_HUDDLE_GUIDELINES: u32 = 48106; @@ -775,6 +777,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_ENDED, + KIND_HUDDLE_LIVENESS, KIND_HUDDLE_GUIDELINES, KIND_MEDIA_UPLOAD, KIND_GIT_REPO_ANNOUNCEMENT, diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c5..fe5b4bb80a6 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -19,344 +19,390 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option bool { +/// Blocked classes are drawn from the IANA IPv4 and IPv6 Special-Purpose +/// Address Space registries (last updated 2025-10-09): ranges whose +/// `Globally Reachable` column is `False`, `None`, or absent, plus multicast +/// space. Within otherwise-denied envelopes, explicitly global entries are +/// carved out as exceptions (e.g., PCP/TURN/DNS-SD anycast inside 2001::/23). +/// IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known +/// (64:ff9b::/96) space is evaluated recursively against the IPv4 table — +/// registry global=True for the IPv6 wrapper does not bypass the +/// embedded-address check. SIIT IPv4-translated (::ffff:0:0:0/96) follows the +/// same recursive path. The local-use NAT64 prefix (64:ff9b:1::/48) is blocked +/// wholesale as a non-global range; its embedded IPv4 payload is not decoded. +/// +/// Used for SSRF protection: rejects outbound targets in known non-public +/// address classes; addresses not covered by an explicit deny rule pass through. +/// Conservative posture: `None`/blank registry entries are treated as non-global. +/// +/// Registries retrieved 2026-08-31; registries last updated 2025-10-09: +/// https://www.iana.org/assignments/iana-ipv4-special-registry/ +/// https://www.iana.org/assignments/iana-ipv6-special-registry/ +/// +/// Compatibility alias: `is_private_ip` (see below). +/// +/// Callers: `buzz-auth` JWKS boundary, `buzz-workflow` webhook SSRF check, +/// desktop `link_preview` SSRF check. +pub fn is_not_global_unicast(ip: &std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => { - let octets = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || octets[0] == 0 - || v4.is_broadcast() - // Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10 - // Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services. - || (octets[0] == 100 && (octets[1] & 0xC0) == 64) - // Benchmarking (RFC 2544) — 198.18.0.0/15 - || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + let o = v4.octets(); + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || o[0] == 0 // 0.0.0.0/8 "This network" + || v4.is_broadcast() // 255.255.255.255 + || (o[0] == 100 && (o[1] & 0xC0) == 64) // 100.64.0.0/10 Shared/CGNAT + || (o[0] == 198 && (o[1] & 0xFE) == 18) // 198.18.0.0/15 Benchmarking + || (o[0] & 0xF0) == 0xE0 // 224.0.0.0/4 Multicast + || (o[0] & 0xF0) == 0xF0 // 240.0.0.0/4 Reserved + // 192.0.0.0/24 IETF Protocol Assignments. + // Globally reachable exceptions: 192.0.0.9 (PCP anycast, RFC 7723) + // and 192.0.0.10 (TURN anycast, RFC 8155). + || (o[0] == 192 && o[1] == 0 && o[2] == 0 + && o[3] != 9 && o[3] != 10) + || (o[0] == 192 && o[1] == 0 && o[2] == 2) // 192.0.2.0/24 TEST-NET-1 + // 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526). + // Registry global field is None/blank — conservative posture: block. + || (o[0] == 192 && o[1] == 88 && o[2] == 99) + || (o[0] == 198 && o[1] == 51 && o[2] == 100) // 198.51.100.0/24 TEST-NET-2 + || (o[0] == 203 && o[1] == 0 && o[2] == 113) // 203.0.113.0/24 TEST-NET-3 } std::net::IpAddr::V6(v6) => { - // Check IPv4-compatible and mapped addresses against IPv4 rules. + // IPv4-compatible and IPv4-mapped addresses are checked against IPv4 rules. if let Some(v4) = v6.to_ipv4() { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - let segments = v6.segments(); + let s = v6.segments(); - // NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4 - // destinations while rejecting embedded private/reserved addresses. + // NAT64 well-known prefix (RFC 6052): reachability follows the embedded + // IPv4 address (registry global=True, but SSRF policy checks payload). if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - // Legacy SIIT IPv4-translated addresses can route to the IPv4 value - // in their final four octets but are not recognized by `to_ipv4()`. + // SIIT IPv4-translated addresses (::ffff:0:0:0/96) route to the embedded + // IPv4 value and are not recognised by `to_ipv4()`. if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); + } + + if v6.is_loopback() || v6.is_unspecified() { + return true; } - v6.is_loopback() - || v6.is_unspecified() - || segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA - || segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local - || segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast - || (segments[0] == 0x0064 - && segments[1] == 0xff9b - && segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64 - || (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo - || segments[0] == 0x2002 // 2002::/16 6to4 - // RFC 3849 — documentation range, should never appear in production - || (segments[0] == 0x2001 && segments[1] == 0x0db8) + // 2001::/23 IETF Protocol Assignments envelope (registry global=False). + // All addresses within the /23 are non-global by default, with explicit + // globally-reachable exceptions carved out below. + // + // /23 check: segments[0]==0x2001 and top 7 bits of segments[1] are zero + // (i.e., segments[1] in [0x0000..0x01ff]). + if s[0] == 0x2001 && (s[1] >> 9) == 0 { + // Globally reachable exceptions inside 2001::/23 (registry global=True): + // 2001:1::1 PCP Anycast RFC 7723 + // 2001:1::2 TURN Anycast RFC 8155 + // 2001:1::3 DNS-SD SRP Anycast RFC 9665 + // 2001:3::/32 AMT RFC 7450 + // 2001:4:112::/48 AS112-v6 RFC 7535 + // 2001:20::/28 ORCHIDv2 RFC 7343 (segments[1] in 0x0020..0x002f) + // 2001:30::/28 DETs Prefix RFC 9374 (segments[1] in 0x0030..0x003f) + let is_global_exception = (s[1] == 1 + && s[2] == 0 + && s[3] == 0 + && s[4] == 0 + && s[5] == 0 + && s[6] == 0 + && matches!(s[7], 1..=3)) + || s[1] == 3 // 2001:3::/32 AMT + || (s[1] == 4 && s[2] == 0x0112) // 2001:4:112::/48 AS112-v6 + || (s[1] >> 4) == 0x0002 // 2001:20::/28 ORCHIDv2 + || (s[1] >> 4) == 0x0003; // 2001:30::/28 DETs + + if !is_global_exception { + return true; + } + } + + s[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA + || s[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local + || s[0] & 0xffc0 == 0xfec0 // fec0::/10 deprecated site-local (RFC 3879) + || s[0] & 0xff00 == 0xff00 // ff00::/8 multicast + // 64:ff9b:1::/48 local-use NAT64 (RFC 8215) + || (s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 1) + // 100::/64 Discard-Only (RFC 6666) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0) + // 100:0:0:1::/64 Dummy IPv6 Prefix (RFC 9780) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 1) + // 2001:db8::/32 Documentation (RFC 3849) — outside 2001::/23 + || (s[0] == 0x2001 && s[1] == 0x0db8) + || s[0] == 0x2002 // 2002::/16 6to4 (RFC 3056) + // 3fff::/20 Documentation (RFC 9637) + || (s[0] == 0x3fff && (s[1] >> 12) == 0) + || s[0] == 0x5f00 // 5f00::/16 SRv6 SIDs (RFC 9252) } } } +/// Compatibility alias; prefer [`is_not_global_unicast`]. +#[inline] +pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { + is_not_global_unicast(ip) +} + #[cfg(test)] mod tests { use super::*; use std::net::IpAddr; - #[test] - fn test_loopback_v4() { - assert!(is_private_ip(&"127.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_10() { - assert!(is_private_ip(&"10.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_172() { - assert!(is_private_ip(&"172.16.0.1".parse::().unwrap())); - } - #[test] - fn test_private_192() { - assert!(is_private_ip(&"192.168.1.1".parse::().unwrap())); - } - #[test] - fn test_link_local() { - assert!(is_private_ip(&"169.254.1.1".parse::().unwrap())); - } - #[test] - fn test_unspecified() { - assert!(is_private_ip(&"0.0.0.0".parse::().unwrap())); - } - #[test] - fn test_broadcast() { - assert!(is_private_ip(&"255.255.255.255".parse::().unwrap())); + fn blocked(s: &str) -> bool { + is_not_global_unicast(&s.parse::().unwrap()) } + #[test] - fn test_public_v4() { - assert!(!is_private_ip(&"8.8.8.8".parse::().unwrap())); + fn public_v4() { + assert!(!blocked("1.1.1.1")); + assert!(!blocked("8.8.8.8")); } + #[test] - fn test_loopback_v6() { - assert!(is_private_ip(&"::1".parse::().unwrap())); + fn public_v6_cloudflare() { + assert!(!blocked("2606:4700::1")); } + #[test] - fn test_unspecified_v6() { - assert!(is_private_ip(&"::".parse::().unwrap())); + fn loopback_and_unspecified() { + assert!(blocked("127.0.0.1")); + assert!(blocked("0.0.0.0")); + assert!(blocked("::1")); + assert!(blocked("::")); } + #[test] - fn test_ula_v6() { - assert!(is_private_ip(&"fd00::1".parse::().unwrap())); + fn private_rfc1918() { + assert!(blocked("10.0.0.1")); + assert!(blocked("172.16.0.1")); + assert!(blocked("192.168.1.1")); } + #[test] - fn test_link_local_v6() { - assert!(is_private_ip(&"fe80::1".parse::().unwrap())); + fn link_local() { + assert!(blocked("169.254.1.1")); + assert!(blocked("fe80::1")); } + #[test] - fn test_public_v6() { - assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); + fn broadcast() { + assert!(blocked("255.255.255.255")); } + #[test] - fn test_documentation_range_v6() { - // 2001:db8::/32 — RFC 3849 documentation range, must be blocked - assert!(is_private_ip(&"2001:db8::1".parse::().unwrap())); - assert!(is_private_ip( - &"2001:db8:ffff::1".parse::().unwrap() - )); + fn cgnat() { + assert!(blocked("100.64.0.1")); + assert!(blocked("100.127.255.254")); + assert!(!blocked("100.63.255.255")); + assert!(!blocked("100.128.0.0")); } + #[test] - fn test_ipv4_mapped_v6_private() { - // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4 - assert!(is_private_ip(&"::ffff:10.0.0.1".parse::().unwrap())); + fn benchmarking_v4() { + assert!(blocked("198.18.0.1")); + assert!(blocked("198.19.255.254")); + assert!(!blocked("198.17.255.255")); + assert!(!blocked("198.20.0.0")); } + #[test] - fn test_ipv4_mapped_v6_loopback() { - assert!(is_private_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); + fn multicast_and_reserved_v4() { + assert!(blocked("224.0.0.0")); + assert!(blocked("239.255.255.255")); + assert!(blocked("240.0.0.0")); + assert!(blocked("254.255.255.255")); + assert!(!blocked("223.255.255.255")); } + + // Most of 192.0.0.0/24 is non-global; 192.0.0.9 (PCP, RFC 7723) and + // 192.0.0.10 (TURN, RFC 8155) are the only globally-reachable exceptions. #[test] - fn test_ipv4_mapped_v6_public() { - assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::().unwrap())); + fn ietf_protocol_assignments() { + assert!(blocked("192.0.0.0")); + assert!(blocked("192.0.0.1")); + assert!(blocked("192.0.0.170")); // NAT64/DNS64 discovery — non-global + assert!(blocked("192.0.0.255")); + assert!(!blocked("192.0.0.9")); // PCP Anycast (RFC 7723) — global + assert!(!blocked("192.0.0.10")); // TURN Anycast (RFC 8155) — global } + #[test] - fn test_ipv4_compatible_v6_private() { - assert!(is_private_ip(&"::10.0.0.1".parse::().unwrap())); - assert!(is_private_ip(&"::127.0.0.1".parse::().unwrap())); - assert!(is_private_ip( - &"::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip(&"::8.8.8.8".parse::().unwrap())); + fn documentation_v4() { + assert!(blocked("192.0.2.0")); + assert!(blocked("192.0.2.255")); + assert!(blocked("198.51.100.0")); + assert!(blocked("198.51.100.255")); + assert!(blocked("203.0.113.0")); + assert!(blocked("203.0.113.255")); + assert!(!blocked("192.0.1.255")); + assert!(!blocked("192.0.3.0")); + assert!(!blocked("198.51.99.255")); + assert!(!blocked("198.51.101.0")); + assert!(!blocked("203.0.112.255")); + assert!(!blocked("203.0.114.0")); } + + // Registry global field is None/blank; conservative posture: block. #[test] - fn test_nat64_well_known_prefix() { - let first = "64:ff9b::".parse().unwrap(); - let last = "64:ff9b::ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - let embedded = "64:ff9b::172.16.1.2".parse().unwrap(); - assert_eq!( - embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX), - Some("172.16.1.2".parse().unwrap()) - ); - assert!(is_private_ip( - &"64:ff9b::10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::().unwrap())); + fn deprecated_6to4_anycast_v4() { + assert!(blocked("192.88.99.0")); + assert!(blocked("192.88.99.1")); + assert!(blocked("192.88.99.255")); + assert!(!blocked("192.88.98.255")); + assert!(!blocked("192.88.100.0")); } + #[test] - fn test_ipv4_translated_prefix() { - let first = "0:0:0:0:ffff:0:0:0".parse().unwrap(); - let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - assert!(is_private_ip( - &"::ffff:0:10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"::ffff:0:8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:fffe:ffff:ffff:ffff".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:ffff:1:0:0".parse::().unwrap() - )); + fn ula_v6() { + assert!(blocked("fd00::1")); + assert!(blocked("fc00::1")); } + #[test] - fn test_nat64_local_use_prefix_boundaries() { - assert!(is_private_ip(&"64:ff9b:1::".parse::().unwrap())); - assert!(is_private_ip( - &"64:ff9b:1:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b:2::".parse::().unwrap())); + fn multicast_v6() { + assert!(blocked("ff02::1")); + assert!(blocked("ff02::2")); + assert!(blocked("ffff::1")); + assert!(!blocked("fe00::1")); } + #[test] - fn test_teredo_prefix_boundaries() { - assert!(is_private_ip(&"2001::".parse::().unwrap())); - assert!(is_private_ip( - &"2001:0:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2001:1::1".parse::().unwrap())); + fn ietf_protocol_assignments_v6_interior() { + assert!(blocked("2001::")); + assert!(blocked("2001:2::1")); + assert!(blocked("2001:10::1")); + assert!(blocked("2001:db8::1")); // Documentation — outside /23 but blocked separately + assert!(blocked("2001:1ff:ffff::1")); + assert!(!blocked("2001:200::1")); } + #[test] - fn test_6to4_prefix_boundaries() { - assert!(is_private_ip(&"2002::".parse::().unwrap())); - assert!(is_private_ip( - &"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2003::1".parse::().unwrap())); + fn ietf_protocol_assignments_v6_global_exceptions() { + // PCP/TURN/DNS-SD anycast /128s — registry global=True + assert!(!blocked("2001:1::1")); // PCP Anycast (RFC 7723) + assert!(!blocked("2001:1::2")); // TURN Anycast (RFC 8155) + assert!(!blocked("2001:1::3")); // DNS-SD SRP Anycast (RFC 9665) + assert!(blocked("2001:1::4")); // not an exception + assert!(blocked("2001:1:1::1")); // not an exception + + // 2001:3::/32 AMT — registry global=True + assert!(!blocked("2001:3::1")); + assert!(!blocked("2001:3:ffff::1")); + assert!(blocked("2001:4::1")); + + // 2001:4:112::/48 AS112-v6 — registry global=True + assert!(!blocked("2001:4:112::1")); + assert!(!blocked("2001:4:112:ffff::1")); + assert!(blocked("2001:4:113::1")); + + // 2001:20::/28 ORCHIDv2 — registry global=True + assert!(!blocked("2001:20::1")); + assert!(!blocked("2001:2f::1")); + assert!(blocked("2001:10::1")); + + // 2001:30::/28 DETs — registry global=True + assert!(!blocked("2001:30::1")); + assert!(!blocked("2001:3f::1")); + assert!(!blocked("2001:3::1")); // AMT exception — distinct check } - // CGNAT (RFC 6598) — 100.64.0.0/10 #[test] - fn test_cgnat_start() { - // 100.64.0.1 — start of CGNAT range - assert!(is_private_ip(&"100.64.0.1".parse::().unwrap())); + fn documentation_v6() { + assert!(blocked("2001:db8::1")); + assert!(blocked("2001:db8:ffff::1")); } + #[test] - fn test_cgnat_end() { - // 100.127.255.254 — end of CGNAT range - assert!(is_private_ip(&"100.127.255.254".parse::().unwrap())); + fn six_to_four_v6() { + assert!(blocked("2002::")); + assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2003::1")); } + #[test] - fn test_cgnat_below_range() { - // 100.63.255.255 — just below CGNAT range (100.0–100.63 is public) - assert!(!is_private_ip(&"100.63.255.255".parse::().unwrap())); + fn discard_only_v6() { + assert!(blocked("100::1")); + assert!(blocked("100::ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:1::1")); // outside both discard and dummy ranges } + #[test] - fn test_cgnat_above_range() { - // 100.128.0.0 — just above CGNAT range (100.128+ is public) - assert!(!is_private_ip(&"100.128.0.0".parse::().unwrap())); + fn dummy_prefix_v6() { + assert!(blocked("100:0:0:1::")); + assert!(blocked("100:0:0:1:ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:0:2::1")); } - // Benchmarking (RFC 2544) — 198.18.0.0/15 #[test] - fn test_benchmarking_start() { - assert!(is_private_ip(&"198.18.0.1".parse::().unwrap())); + fn nat64_local_use_v6() { + assert!(blocked("64:ff9b:1::")); + assert!(blocked("64:ff9b:1:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("64:ff9b:2::")); } + #[test] - fn test_benchmarking_end() { - assert!(is_private_ip(&"198.19.255.254".parse::().unwrap())); + fn documentation_3fff_v6() { + assert!(blocked("3fff::1")); + assert!(blocked("3fff:0fff::1")); + assert!(!blocked("3fff:1000::1")); + assert!(!blocked("3ffe::1")); } + #[test] - fn test_benchmarking_below_range() { - // 198.17.255.255 — just below benchmarking range - assert!(!is_private_ip(&"198.17.255.255".parse::().unwrap())); + fn srv6_sids_v6() { + assert!(blocked("5f00::1")); + assert!(blocked("5f00:ffff::1")); + assert!(!blocked("5e00::1")); + assert!(!blocked("5fff::1")); // 5fff ≠ 5f00 — outside /16 } + #[test] - fn test_benchmarking_above_range() { - // 198.20.0.0 — just above benchmarking range - assert!(!is_private_ip(&"198.20.0.0".parse::().unwrap())); + fn nat64_well_known_v6() { + assert!(blocked("64:ff9b::10.0.0.1")); // private embedded + assert!(blocked("64:ff9b::127.0.0.1")); // loopback embedded + assert!(blocked("64:ff9b::169.254.169.254")); // link-local embedded + assert!(!blocked("64:ff9b::8.8.8.8")); // public embedded — policy follows payload + assert!(!blocked("64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff")); // different prefix + assert!(!blocked("64:ff9b::1:0:0")); // outside /96 } - // IPv6 multicast — ff00::/8 #[test] - fn test_ipv6_multicast_all_nodes() { - // ff02::1 — all-nodes multicast - assert!(is_private_ip(&"ff02::1".parse::().unwrap())); + fn ipv4_translated_v6() { + assert!(blocked("::ffff:0:10.0.0.1")); + assert!(blocked("::ffff:0:127.0.0.1")); + assert!(!blocked("::ffff:0:8.8.8.8")); + assert!(!blocked("0:0:0:0:fffe:ffff:ffff:ffff")); // outside prefix } + #[test] - fn test_ipv6_multicast_all_routers() { - // ff02::2 — all-routers multicast - assert!(is_private_ip(&"ff02::2".parse::().unwrap())); + fn ipv4_mapped_v6() { + assert!(blocked("::ffff:10.0.0.1")); + assert!(blocked("::ffff:127.0.0.1")); + assert!(!blocked("::ffff:8.8.8.8")); } + #[test] - fn test_ipv6_multicast_high() { - // ffff::1 — still in ff00::/8 - assert!(is_private_ip(&"ffff::1".parse::().unwrap())); + fn ipv4_compatible_v6() { + assert!(blocked("::10.0.0.1")); + assert!(blocked("::127.0.0.1")); + assert!(!blocked("::8.8.8.8")); } + #[test] - fn test_ipv6_not_multicast() { - // fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA) - assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); + fn deprecated_site_local_fec0() { + // fec0::/10 — deprecated IPv6 site-local (RFC 3879); blocked as non-global. + assert!(blocked("fec0::1")); + assert!(blocked("feff::1")); // fec0::/10 boundary } } diff --git a/crates/buzz-db/TESTING.md b/crates/buzz-db/TESTING.md new file mode 100644 index 00000000000..2cf6c7828b2 --- /dev/null +++ b/crates/buzz-db/TESTING.md @@ -0,0 +1,63 @@ +# PostgreSQL-backed tests in buzz-db + +The dedicated PostgreSQL CI lane discovers tests and Cargo packages by +structure rather than by exact lists. Follow this checklist so a new database +test is run automatically and remains safe under parallel execution. + +## Adding a test + +1. Put the test in a module whose name ends in `postgres_tests`. +2. Mark it `#[ignore = "requires Postgres"]` so infrastructure-free unit-test + jobs stay fast. +3. Connect through `crate::test_support::database_url()`. The CI wrapper sets + this helper's environment to a unique database for each test process; never + hard-code the shared development database. +4. Keep tests that need infrastructure beyond PostgreSQL and Redis in an + `external_infra*_tests` module. The PostgreSQL lane excludes those tests. +5. Run `scripts/test-postgres-test-discovery.sh` after adding or moving the + test. The same guard runs in CI immediately after changed-path detection. + +The wrapper isolates destructive tests by dropping the entire per-test +database after the process exits. It does not `DELETE` rows or `TRUNCATE` +shared tables, so tests may run concurrently without coordinating cleanup. + +## Choose the schema intentionally + +Most tests use the committed desired-state schema from `schema/schema.sql`. +That is the default and is appropriate for data-access behavior. + +Tests in `migration::postgres_tests` receive an empty database and own the +embedded migration lifecycle. A test outside that module that intentionally +depends on migration-created triggers or seed rows must prefix its function +name with `migration_schema_`; it also receives an empty database with +`BUZZ_TEST_SCHEMA_MODE=migration`. + +Helpers that normally run migrations honor `BUZZ_TEST_SCHEMA_MODE=desired` in +the default lane. Do not rerun migrations against a desired-state database. +When behavior should match in both schema paths, add explicit desired-state and +migration-applied coverage rather than making the bootstrap implicit. + +Tests that inspect cluster-wide PostgreSQL state or open least-privilege +sessions include `cluster_global_` in the function name. Migration-backed cases +use `migration_schema_cluster_global_`. Nextest serializes this small group +because separate databases still share `pg_stat_activity` and roles. + +## Run the lane locally + +Start native PostgreSQL and Redis, activate Hermit, and run: + +```bash +. ./bin/activate-hermit +scripts/test-postgres-test-discovery.sh +scripts/postgres-test-run.sh +``` + +Set `BUZZ_POSTGRES_ADMIN_URL` to a PostgreSQL maintenance database owned by a +role that can create and drop databases. Set `PGHOST`, `PGPORT`, `PGUSER`, and +`PGPASSWORD` for desired-state bootstrap, plus `REDIS_URL` for Redis-backed +tests. The complete privilege-boundary inventory also needs `CREATEROLE` and +membership in `pg_read_all_stats`, or an ephemeral superuser as CI uses. + +The runner creates one desired-state source database per invocation and clones +it for ordinary tests. Migration-mode tests start empty. Cleanup retries +transient disconnect races before reporting a warning. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index ea81bc354b8..6f8d0ffb3d4 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -27,9 +27,21 @@ mod store; /// Database error types. pub mod error; +#[cfg(test)] +mod test_support; + pub use runtime::{ - insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, ReadSession, + insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, DbReadinessOutcome, + ReadSession, }; + +/// Valid low-cardinality `(pool_role, operation)` pairs for pool-acquisition telemetry. +pub const DB_POOL_ACQUIRE_VALID_PAIRS: [(&str, &str); 11] = + runtime::observability::POOL_ACQUIRE_VALID_PAIRS; + +/// Raw Prometheus series ceiling per relay pod for the operation-aware contract. +pub const DB_POOL_ACQUIRE_RAW_SERIES_PER_POD: usize = + runtime::observability::POOL_ACQUIRE_RAW_SERIES_PER_POD; pub(crate) use runtime::{ insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, RoutePredicate, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 66251563cbd..59015125042 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -82,9 +82,21 @@ where F: FnOnce(PgConnection) -> Fut, Fut: Future)>, { - let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer) - .await? - .detach(); + let mut lock_conn = crate::observability::acquire_writer_with_legacy_metrics( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await? + .detach(); + // This dedicated connection intentionally waits for the current migration + // or schema-destruction owner and may then run long DDL. Exempt those two + // phases from runtime lock/statement budgets. Keep the idle-in-transaction + // timeout: a client wedged idle mid-migration is still a lock holder that + // should be reaped. The detached connection is closed below and never + // returns these session settings to the pool. + sqlx::raw_sql("SET lock_timeout = 0; SET statement_timeout = 0") + .execute(&mut lock_conn) + .await?; crate::observability::observe_advisory_lock( crate::observability::LockType::MigrationSchemaSafety, sqlx::query("SELECT pg_advisory_lock($1)") @@ -174,7 +186,7 @@ async fn reject_legacy_nip_rs_cardinality_ambiguity(conn: &mut PgConnection) -> } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use std::{ collections::BTreeSet, @@ -184,8 +196,8 @@ mod tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - /// Connection parameters parsed out of a `postgres://user:pass@host:port/db` - /// URL so the parity test can pass them to the `bin/pgschema` binary, which + /// Connection parameters parsed out of a PostgreSQL URL so the parity test + /// can pass them to the `bin/pgschema` binary, which /// takes discrete `--host/--port/--user/--password/--db` flags rather than a /// URL. Only the shapes this test emits (`BUZZ_TEST_DATABASE_URL` / /// `DATABASE_URL` / `TEST_DB_URL`) are supported. @@ -690,7 +702,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 40); + assert_eq!(migrations.len(), 44); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1232,6 +1244,49 @@ mod tests { operator_audit.contains("_operator_global_tables"), "migration 39 must register relay_operator_audit in _operator_global_tables" ); + + assert_eq!(migrations[40].version, 41); + let identity_foundation = migrations[40].sql.as_str(); + assert!(identity_foundation.contains("CREATE TABLE identity_bindings")); + assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history")); + + assert_eq!(migrations[41].version, 42); + let authorization_foundation = migrations[41].sql.as_str(); + assert!(authorization_foundation.contains("CREATE TABLE authorization_events")); + assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority")); + + // Brownfield relay databases created through SQLx still carry the + // production/sandbox constraint from 0015. Converge them to the same + // dogfood-only authority declared by the desired-state schema. + assert_eq!(migrations[42].version, 43); + let dogfood_profile = migrations[42].sql.as_str(); + assert!(dogfood_profile.contains("DELETE FROM push_gateway_delegations")); + assert!(dogfood_profile.contains("DELETE FROM push_gateway_installations")); + assert!(dogfood_profile + .contains("DROP CONSTRAINT push_gateway_installations_app_profile_check")); + assert!(dogfood_profile.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); + assert!(desired_schema.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); + + // Drop the Phase-A NIP-FI relay-side authority ledger (0041 + 0042). + // OSS Buzz is stateless for identity (spec v2, PR #7214); the durable + // ledger tables are dead code. Restores community_write_fence_excluded_table + // to its pre-0041 body so the deletion catalog no longer includes the + // removed relations. + assert_eq!(migrations[43].version, 44); + let ledger_removal = migrations[43].sql.as_str(); + assert!(ledger_removal.contains("DROP TABLE authorization_operation_receipts")); + assert!(ledger_removal.contains("DROP TABLE identity_bindings")); + assert!(ledger_removal.contains("DROP TABLE authorization_events")); + assert!(ledger_removal + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + // The restored exclusion function must NOT list any NIP-FI relation. + assert!(!ledger_removal.contains("'authorization_operation_receipts'")); + assert!(!ledger_removal.contains("'identity_bindings'")); + // schema.sql exclusion list must match the restored (pre-0041) body. + assert!( + desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), + "schema.sql exclusion list must match the pre-0041 body after ledger removal" + ); } #[test] @@ -2609,4 +2664,98 @@ mod tests { .await .expect("drop late-table fixtures"); } + + /// Verify migration 0044 applies cleanly against a DB that has rows in + /// the NIP-FI 0041+0042 tables. The immutability guards (no_delete, + /// no_truncate) are enforced via triggers; DROP TABLE bypasses them and + /// must succeed even when rows are present. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_0044_drops_populated_nip_fi_ledger_cleanly() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + // Seed a community and minimal rows in a selection of 0041+0042 tables. + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("drop-test-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("seed community"); + + // Seed a receipt (used as FK anchor for several 0042 tables). + let operation_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .bind(vec![0xCC_u8; 32]) + .execute(&pool) + .await + .expect("seed operation receipt"); + + // Seed an invalidation domain (0042 table with no FK to receipts). + sqlx::query( + "INSERT INTO authorization_invalidation_domains \ + (community_id, current_generation) VALUES ($1, 0)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("seed invalidation domain"); + + // Apply migration 0043 (dogfood profile) and 0044 (ledger removal). + MIGRATOR + .run_to(44, &pool) + .await + .expect("migration 0044 must apply cleanly against a populated NIP-FI DB"); + + // All NIP-FI tables must be gone. + let nip_fi_tables = [ + "authorization_admission_results", + "authorization_authentication_denial_attempts", + "authorization_authority_epochs", + "authorization_event_capacity", + "authorization_events", + "authorization_invalidation_domains", + "authorization_invalidation_floors", + "authorization_operation_receipts", + "authorization_operation_version_delta_manifests", + "authorization_operation_version_deltas", + "identity_bindings", + "identity_enrollment_policies", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + "protected_object_authority", + ]; + let present: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = ANY($1)", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("catalog check after ledger removal"); + assert!( + present.is_empty(), + "all NIP-FI tables must be absent after migration 0044: {present:?}" + ); + + // The deletion catalog must validate with ledger relations gone. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migration 0044"); + } } diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 29eef884024..5f608cb78fc 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -24,7 +24,9 @@ pub async fn insert_mentions( event: &nostr::Event, channel_id: Option, ) -> Result<()> { - let mut tx = pool.begin().await?; + let connection = + observability::acquire_writer(pool, observability::WriterOperation::EventWrite).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?; tx.commit().await?; Ok(()) @@ -425,6 +427,26 @@ pub struct DbPoolStats { pub max: u32, } +/// Bounded outcome of the Postgres portion of a relay readiness check. +/// +/// The variants deliberately separate waiting for a pooled connection from +/// executing the health query. Callers may safely use the variant names as +/// low-cardinality metric labels; detailed SQLx errors remain in logs rather +/// than becoming labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DbReadinessOutcome { + /// A writer-pool connection was acquired and `SELECT 1` succeeded. + Success, + /// No writer-pool connection became available before the readiness deadline. + PoolTimeout, + /// The writer pool returned a non-timeout acquisition error. + PoolError, + /// A connection was acquired, but `SELECT 1` exceeded the readiness deadline. + QueryTimeout, + /// A connection was acquired, but `SELECT 1` returned an error. + QueryError, +} + /// Configuration for the Postgres connection pool. #[derive(Debug, Clone)] pub struct DbConfig { @@ -454,6 +476,16 @@ pub struct DbConfig { /// than the staleness gate never routes anyway, so a larger budget /// would only misrepresent the config. pub replica_read_max_age_ms: u64, + /// Session `lock_timeout` in milliseconds for writer connections (env + /// `BUZZ_DB_LOCK_TIMEOUT_MS`). `0` disables the timeout. + pub lock_timeout_ms: u64, + /// Session `idle_in_transaction_session_timeout` in milliseconds for + /// writer connections (env `BUZZ_DB_IDLE_TXN_TIMEOUT_MS`). `0` disables. + pub idle_txn_timeout_ms: u64, + /// Session `statement_timeout` in milliseconds for writer connections + /// (env `BUZZ_DB_STATEMENT_TIMEOUT_MS`). `0` disables it and is the + /// default because migrations and backfills may legitimately run long. + pub statement_timeout_ms: u64, } impl Default for DbConfig { @@ -471,7 +503,44 @@ impl Default for DbConfig { max_lifetime_secs: 1800, idle_timeout_secs: 600, replica_read_max_age_ms: 0, + lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS, + idle_txn_timeout_ms: DEFAULT_IDLE_TXN_TIMEOUT_MS, + statement_timeout_ms: 0, + } + } +} + +/// Default writer `lock_timeout` in milliseconds. +pub const DEFAULT_LOCK_TIMEOUT_MS: u64 = 5_000; + +/// Default writer `idle_in_transaction_session_timeout` in milliseconds. +pub const DEFAULT_IDLE_TXN_TIMEOUT_MS: u64 = 60_000; + +impl DbConfig { + /// Overlay writer session timeouts from the shared `BUZZ_DB_*_TIMEOUT_MS` + /// environment variables. Missing or invalid values retain the existing + /// configuration; explicit zeroes pass through to disable a timeout. + /// + /// This belongs in `buzz-db` so relay, admin, deletion, and audit writers + /// share one policy. The separately deployed push gateway owns its own + /// database and session policy. + pub fn with_session_timeouts_from_env(mut self) -> Self { + fn parse(key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|value| value.parse::().ok()) + } + + if let Some(value) = parse("BUZZ_DB_LOCK_TIMEOUT_MS") { + self.lock_timeout_ms = value; } + if let Some(value) = parse("BUZZ_DB_IDLE_TXN_TIMEOUT_MS") { + self.idle_txn_timeout_ms = value; + } + if let Some(value) = parse("BUZZ_DB_STATEMENT_TIMEOUT_MS") { + self.statement_timeout_ms = value; + } + self } } @@ -486,7 +555,7 @@ impl Db { /// `buzz.created_at_floor` GUC — this is what makes the replica fence /// proof hold for every insert path that goes through this pool. pub async fn new(config: &DbConfig) -> Result { - let pool = Self::connect_pool(config, &config.database_url).await?; + let pool = Self::connect_writer_pool(config).await?; let read_max_connections = config .read_max_connections .unwrap_or(config.max_connections); @@ -511,20 +580,44 @@ impl Db { /// SQLx stores one `after_connect` hook, so the floor guard and transaction /// isolation assertion must remain in this single closure. Registering a /// second hook replaces the first and silently disarms the floor trigger. - async fn connect_pool(config: &DbConfig, url: &str) -> Result { + /// Additional writer pools, including the relay audit pool, must use this + /// constructor so they inherit the timeout, floor-guard, and isolation + /// policy installed by [`Db::new`]. + pub async fn connect_writer_pool(config: &DbConfig) -> Result { + let lock_timeout_ms = config.lock_timeout_ms; + let idle_txn_timeout_ms = config.idle_txn_timeout_ms; + let statement_timeout_ms = config.statement_timeout_ms; let options = PgPoolOptions::new() .max_connections(config.max_connections) .min_connections(config.min_connections) .acquire_timeout(Duration::from_secs(config.acquire_timeout_secs)) .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) - .after_connect(|conn, _meta| { + .after_connect(move |conn, _meta| { Box::pin(async move { // `SET` cannot take bind parameters; `set_config` can. sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) .execute(&mut *conn) .await?; + // `lock_timeout` fails the waiting statement; it does not + // cancel the holder. `idle_in_transaction_session_timeout` + // reaps only holders idling inside an open transaction, + // while actively executing holders are bounded only by + // `statement_timeout` (off by default). Bare values are + // milliseconds. Migration/schema-destruction connections + // reset lock and statement timeouts before their intentional + // long wait (see `with_exclusive_schema_destruction_lock`). + sqlx::query( + "SELECT set_config('lock_timeout', $1, false), \ + set_config('idle_in_transaction_session_timeout', $2, false), \ + set_config('statement_timeout', $3, false)", + ) + .bind(lock_timeout_ms.to_string()) + .bind(idle_txn_timeout_ms.to_string()) + .bind(statement_timeout_ms.to_string()) + .execute(&mut *conn) + .await?; let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") .fetch_one(&mut *conn) .await?; @@ -539,7 +632,7 @@ impl Db { Ok(()) }) }); - Ok(options.connect(url).await?) + Ok(options.connect(&config.database_url).await?) } /// Reader acquire timeout — deliberately far below the writer's @@ -596,25 +689,43 @@ impl Db { return; }; let aurora_identity = self.reader_aurora_identity.clone(); - tokio::spawn(async move { - match observability::acquire(&read_pool, observability::PoolRole::Reader).await { - Ok(mut conn) => { - tracing::info!("read replica reachable at boot"); - match replica_fence::reader_supports_aurora_identity(&mut conn).await { - Ok(supported) => { - let _ = aurora_identity.set(supported); - } - Err(e) => tracing::debug!( - error = %e, - "aurora identity boot prime failed; first routed read will probe" - ), + tokio::spawn(Self::read_pool_boot_ping_once(read_pool, aurora_identity)); + } + + async fn read_pool_boot_ping_once( + read_pool: PgPool, + aurora_identity: std::sync::Arc>, + ) { + match observability::acquire_reader_with_legacy_metrics( + &read_pool, + observability::ReaderOperation::Bootstrap, + ) + .await + { + Ok(mut conn) => { + tracing::info!("read replica reachable at boot"); + match replica_fence::reader_supports_aurora_identity(&mut conn).await { + Ok(supported) => { + let _ = aurora_identity.set(supported); } + Err(e) => tracing::debug!( + error = %e, + "aurora identity boot prime failed; first routed read will probe" + ), } - Err(e) => tracing::warn!( - "read replica unreachable at boot; serving all-writer until it recovers: {e}" - ), } - }); + Err(e) => tracing::warn!( + "read replica unreachable at boot; serving all-writer until it recovers: {e}" + ), + } + } + + #[cfg(test)] + pub(crate) async fn read_pool_boot_ping_for_tests(&self) { + let Some(read_pool) = self.read_pool.clone() else { + return; + }; + Self::read_pool_boot_ping_once(read_pool, self.reader_aurora_identity.clone()).await; } /// Creates a `Db` from an existing `PgPool` (useful in tests). @@ -680,8 +791,7 @@ impl Db { if self.read_pool.is_none() { return Ok(false); } - replica_fence::verify_floor_guard_catalog(&self.pool).await?; - replica_fence::verify_floor_guard_behavior(&self.pool).await?; + self.verify_replica_fence_at_boot().await?; tokio::spawn(replica_fence::run_probe( self.pool.clone(), std::sync::Arc::clone(&self.fence), @@ -689,6 +799,17 @@ impl Db { Ok(true) } + /// Verify replica-fence catalog shape and behavior through attributed + /// writer/bootstrap acquisitions without starting the recurring probe. + pub(crate) async fn verify_replica_fence_at_boot(&self) -> Result<()> { + let mut connection = + observability::acquire_writer(&self.pool, observability::WriterOperation::Bootstrap) + .await?; + replica_fence::verify_floor_guard_catalog(&mut *connection).await?; + drop(connection); + replica_fence::verify_floor_guard_behavior(&self.pool).await + } + /// The pool for lag-tolerant reads: the read replica when configured, /// otherwise the writer pool. /// @@ -726,6 +847,7 @@ impl Db { async fn proved_reader( &self, read_pool: &PgPool, + operation: observability::ReaderOperation, ) -> std::result::Result< ( sqlx::Transaction<'static, sqlx::Postgres>, @@ -739,7 +861,9 @@ impl Db { // `read_pool` separately would spend a second budget whenever the // capability is uncached — i.e. after a failed boot ping, which is // precisely the reader-unavailable case the bound must hold for. - let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { + let conn = match observability::acquire_reader_with_legacy_metrics(read_pool, operation) + .await + { Ok(conn) => conn, Err(sqlx::Error::PoolTimedOut) => { tracing::warn!("reader pool acquire timed out; routing to writer"); @@ -860,9 +984,62 @@ impl Db { migration::run_migrations(&self.pool).await } - /// Returns `true` if the database is reachable (used by readiness probes). + /// Returns `true` if the database is reachable. pub async fn ping(&self) -> bool { - sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() + let Ok(mut connection) = + observability::acquire_writer(&self.pool, observability::WriterOperation::Readiness) + .await + else { + return false; + }; + sqlx::query("SELECT 1") + .execute(&mut *connection) + .await + .is_ok() + } + + /// Checks writer-pool acquisition and query execution against one deadline. + /// + /// Unlike [`Self::ping`], this preserves whether readiness was blocked while + /// borrowing a connection or failed after a connection had been acquired. + /// The query runs on the already-acquired connection so the two phases + /// cannot be collapsed into a second implicit pool acquisition. + pub async fn readiness_check(&self, deadline: tokio::time::Instant) -> DbReadinessOutcome { + self.readiness_check_sql(deadline, "SELECT 1").await + } + + /// Production-bound seam for classifying failures after pool acquisition. + /// Tests vary only the SQL so timeout/error/cancellation paths execute the + /// same acquisition and classification code as [`Self::readiness_check`]. + async fn readiness_check_sql( + &self, + deadline: tokio::time::Instant, + query: &'static str, + ) -> DbReadinessOutcome { + let mut connection = match observability::acquire_writer_until( + &self.pool, + observability::WriterOperation::Readiness, + deadline, + ) + .await + { + Err(sqlx::Error::PoolTimedOut) => return DbReadinessOutcome::PoolTimeout, + Err(error) => { + tracing::debug!(error = %error, "Postgres readiness pool acquisition failed"); + return DbReadinessOutcome::PoolError; + } + Ok(connection) => connection, + }; + + match tokio::time::timeout_at(deadline, sqlx::query(query).execute(&mut *connection)).await + { + Err(_) => DbReadinessOutcome::QueryTimeout, + Ok(Err(error)) => { + tracing::debug!(error = %error, "Postgres readiness query failed"); + DbReadinessOutcome::QueryError + } + Ok(Ok(_)) => DbReadinessOutcome::Success, + } } /// Returns pool utilisation stats for metrics emission. @@ -878,6 +1055,15 @@ impl Db { } } + /// Refresh all expected operation-specific waiter gauges, including zero. + /// + /// The relay pool sampler calls this periodically so an exporter idle + /// timeout cannot make a healthy zero indistinguishable from missing + /// telemetry. + pub fn refresh_pool_waiter_metrics(&self) { + observability::refresh_pool_waiters(self.read_pool.is_some()); + } + /// Pool utilisation stats for the read-replica pool, when configured. /// /// `max` is the **reader's** ceiling ([`Db::read_max_connections`]), not @@ -898,14 +1084,29 @@ impl Db { /// /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. /// The transaction holds an owned pool handle, not a borrow. - pub async fn begin_transaction(&self) -> Result> { - let connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + pub async fn begin_event_write_transaction( + &self, + ) -> Result> { + let connection = observability::acquire_writer_with_legacy_metrics( + &self.pool, + observability::WriterOperation::EventWrite, + ) + .await?; sqlx::Transaction::begin(connection, None) .await .map_err(Into::into) } + /// Begin an event-write transaction through the pre-operation API name. + /// + /// New callers should use [`Self::begin_event_write_transaction`] so the + /// semantic intent is explicit. This alias preserves the crate's public + /// API while emitting the same operation-aware and compatibility metrics. + #[deprecated(note = "use Db::begin_event_write_transaction")] + pub async fn begin_transaction(&self) -> Result> { + self.begin_event_write_transaction().await + } + /// Insert an event while holding and validating an admitted serving-write /// lease under the community ordering lock through commit. /// @@ -928,7 +1129,10 @@ impl Db { return Err(DbError::EphemeralEventRejected(kind_u16)); } - let mut tx = self.pool.begin().await?; + let connection = + observability::acquire_writer(&self.pool, observability::WriterOperation::EventWrite) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; self.deletion_store() .guard_transaction_with_serving_lease(&mut tx, lease) .await?; @@ -956,6 +1160,7 @@ impl Db { &self, path: &'static str, predicate: RoutePredicate, + operation: observability::ReaderOperation, ) -> RouteDecision { let Some(read_pool) = &self.read_pool else { Self::record_route(path, "writer", "disabled"); @@ -998,7 +1203,7 @@ impl Db { Self::record_route(path, "writer", reason); return RouteDecision::Writer; } - match self.proved_reader(read_pool).await { + match self.proved_reader(read_pool, operation).await { Ok((tx, entry)) => { // Re-evaluate against the entry the session actually proved // (it may be older than the shared newest). @@ -1041,4 +1246,5 @@ impl Db { } #[cfg(test)] -mod tests; +#[path = "tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-db/src/runtime/observability.rs b/crates/buzz-db/src/runtime/observability.rs index afe1d20b305..4d2e5f7ad69 100644 --- a/crates/buzz-db/src/runtime/observability.rs +++ b/crates/buzz-db/src/runtime/observability.rs @@ -4,26 +4,156 @@ //! never derive labels from tenant data, events, SQL text, or query identifiers. use std::future::Future; +use std::sync::Mutex; use std::time::{Duration, Instant}; +/// One valid pool/operation acquisition family. +/// +/// Keeping role and operation in one enum makes invalid combinations +/// unrepresentable at call sites and gives the series budget one exhaustive +/// source of truth. +#[repr(usize)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PoolRole { - Writer, - Reader, +enum PoolOperation { + WriterBootstrap, + ReaderBootstrap, + WriterReadiness, + WriterTenantResolution, + WriterAuthentication, + WriterAuthorization, + ReaderAuthorization, + WriterSubscriptionHistory, + ReaderSubscriptionHistory, + WriterEventWrite, + WriterMaintenance, } -impl PoolRole { +/// Writer-pool operations. Reader-only combinations cannot be constructed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WriterOperation { + Bootstrap, + Readiness, + TenantResolution, + Authentication, + Authorization, + SubscriptionHistory, + EventWrite, + Maintenance, +} + +impl WriterOperation { #[cfg(test)] - pub(crate) const ALL: [Self; 2] = [Self::Writer, Self::Reader]; + const ALL: [Self; 8] = [ + Self::Bootstrap, + Self::Readiness, + Self::TenantResolution, + Self::Authentication, + Self::Authorization, + Self::SubscriptionHistory, + Self::EventWrite, + Self::Maintenance, + ]; - pub(crate) const fn as_str(self) -> &'static str { + const fn pair(self) -> PoolOperation { + match self { + Self::Bootstrap => PoolOperation::WriterBootstrap, + Self::Readiness => PoolOperation::WriterReadiness, + Self::TenantResolution => PoolOperation::WriterTenantResolution, + Self::Authentication => PoolOperation::WriterAuthentication, + Self::Authorization => PoolOperation::WriterAuthorization, + Self::SubscriptionHistory => PoolOperation::WriterSubscriptionHistory, + Self::EventWrite => PoolOperation::WriterEventWrite, + Self::Maintenance => PoolOperation::WriterMaintenance, + } + } +} + +/// Reader-pool operations. Writer-only combinations cannot be constructed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReaderOperation { + Bootstrap, + Authorization, + SubscriptionHistory, +} + +impl ReaderOperation { + #[cfg(test)] + const ALL: [Self; 3] = [ + Self::Bootstrap, + Self::Authorization, + Self::SubscriptionHistory, + ]; + + const fn pair(self) -> PoolOperation { + match self { + Self::Bootstrap => PoolOperation::ReaderBootstrap, + Self::Authorization => PoolOperation::ReaderAuthorization, + Self::SubscriptionHistory => PoolOperation::ReaderSubscriptionHistory, + } + } +} + +impl PoolOperation { + pub(crate) const ALL: [Self; 11] = [ + Self::WriterBootstrap, + Self::ReaderBootstrap, + Self::WriterReadiness, + Self::WriterTenantResolution, + Self::WriterAuthentication, + Self::WriterAuthorization, + Self::ReaderAuthorization, + Self::WriterSubscriptionHistory, + Self::ReaderSubscriptionHistory, + Self::WriterEventWrite, + Self::WriterMaintenance, + ]; + + pub(crate) const fn pool_role(self) -> &'static str { + match self { + Self::ReaderBootstrap | Self::ReaderAuthorization | Self::ReaderSubscriptionHistory => { + "reader" + } + _ => "writer", + } + } + + pub(crate) const fn operation(self) -> &'static str { match self { - Self::Writer => "writer", - Self::Reader => "reader", + Self::WriterBootstrap | Self::ReaderBootstrap => "bootstrap", + Self::WriterReadiness => "readiness", + Self::WriterTenantResolution => "tenant_resolution", + Self::WriterAuthentication => "authentication", + Self::WriterAuthorization | Self::ReaderAuthorization => "authorization", + Self::WriterSubscriptionHistory | Self::ReaderSubscriptionHistory => { + "subscription_history" + } + Self::WriterEventWrite => "event_write", + Self::WriterMaintenance => "maintenance", } } + + const fn index(self) -> usize { + self as usize + } } +pub(crate) const POOL_ACQUIRE_VALID_PAIRS: [(&str, &str); 11] = [ + ("writer", "bootstrap"), + ("reader", "bootstrap"), + ("writer", "readiness"), + ("writer", "tenant_resolution"), + ("writer", "authentication"), + ("writer", "authorization"), + ("reader", "authorization"), + ("writer", "subscription_history"), + ("reader", "subscription_history"), + ("writer", "event_write"), + ("writer", "maintenance"), +]; + +/// Eleven valid pairs × (12 histogram series + 4 outcome counters + 1 gauge). +pub(crate) const POOL_ACQUIRE_RAW_SERIES_PER_POD: usize = POOL_ACQUIRE_VALID_PAIRS.len() * 17; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum LockType { Replacement, @@ -59,17 +189,19 @@ pub(crate) enum Outcome { Success, Error, Timeout, + Cancelled, } impl Outcome { #[cfg(test)] - pub(crate) const ALL: [Self; 3] = [Self::Success, Self::Error, Self::Timeout]; + pub(crate) const ALL: [Self; 4] = [Self::Success, Self::Error, Self::Timeout, Self::Cancelled]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::Success => "success", Self::Error => "error", Self::Timeout => "timeout", + Self::Cancelled => "cancelled", } } @@ -115,42 +247,256 @@ impl TransactionOperation { Self::FenceCommunityDeletion => "fence_community_deletion", } } + + const fn writer_operation(self) -> WriterOperation { + match self { + Self::ReplaceParameterizedEvent + | Self::ReplaceAddressableEvent + | Self::PublishNip43MembershipLocked + | Self::AcceptPushLeaseEvent => WriterOperation::EventWrite, + Self::BeginCommunityDeletionQuiescing | Self::FenceCommunityDeletion => { + WriterOperation::Maintenance + } + } + } } -pub(crate) fn record_pool_acquire(role: PoolRole, outcome: Outcome, elapsed: Duration) { +fn record_pool_acquire( + pair: PoolOperation, + outcome: Outcome, + elapsed: Duration, + emit_legacy: bool, +) { + // Preserve the original observed population for existing dashboards. + // Newly instrumented raw-pool seams must not create a deployment-time + // discontinuity in these compatibility families. + if emit_legacy && outcome != Outcome::Cancelled { + metrics::histogram!( + "buzz_db_pool_acquire_wait_seconds", + "pool_role" => pair.pool_role(), + "outcome" => outcome.as_str(), + ) + .record(elapsed.as_secs_f64()); + metrics::counter!( + "buzz_db_pool_acquisitions_total", + "pool_role" => pair.pool_role(), + "outcome" => outcome.as_str(), + ) + .increment(1); + } + metrics::histogram!( - "buzz_db_pool_acquire_wait_seconds", - "pool_role" => role.as_str(), - "outcome" => outcome.as_str(), + "buzz_db_pool_acquire_duration_seconds", + "pool_role" => pair.pool_role(), + "operation" => pair.operation(), ) .record(elapsed.as_secs_f64()); metrics::counter!( - "buzz_db_pool_acquisitions_total", - "pool_role" => role.as_str(), + "buzz_db_pool_acquire_attempts_total", + "pool_role" => pair.pool_role(), + "operation" => pair.operation(), "outcome" => outcome.as_str(), ) .increment(1); } -pub(crate) async fn acquire( +static POOL_WAITERS: [Mutex; PoolOperation::ALL.len()] = + [const { Mutex::new(0) }; PoolOperation::ALL.len()]; + +#[cfg(test)] +static POOL_METRICS_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[cfg(test)] +#[derive(Clone)] +struct WaiterPublishTestHook { + pair: PoolOperation, + value: u64, + entered: std::sync::Arc, + release: std::sync::Arc, + armed: std::sync::Arc, +} + +#[cfg(test)] +static WAITER_PUBLISH_TEST_HOOK: Mutex> = Mutex::new(None); + +#[cfg(test)] +static WAITER_LAST_PUBLISHED: [Mutex; PoolOperation::ALL.len()] = + [const { Mutex::new(u64::MAX) }; PoolOperation::ALL.len()]; + +fn publish_waiters(pair: PoolOperation, value: u64) { + #[cfg(test)] + { + let hook = WAITER_PUBLISH_TEST_HOOK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(hook) = hook { + if hook.pair == pair + && hook.value == value + && hook.armed.swap(false, std::sync::atomic::Ordering::SeqCst) + { + hook.entered.wait(); + hook.release.wait(); + } + } + *WAITER_LAST_PUBLISHED[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = value; + } + metrics::gauge!( + "buzz_db_pool_waiters", + "pool_role" => pair.pool_role(), + "operation" => pair.operation(), + ) + .set(value as f64); +} + +/// Re-publish every valid waiter pair, including healthy zero, so exporter +/// idle eviction cannot turn an expected zero into ambiguous missing data. +pub(crate) fn refresh_pool_waiters(include_reader: bool) { + for pair in PoolOperation::ALL { + if pair.pool_role() == "reader" && !include_reader { + continue; + } + let waiters = POOL_WAITERS[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + publish_waiters(pair, *waiters); + } +} + +/// Owns one polled connection acquisition until exactly one terminal. +/// +/// Because async function bodies do not run until first poll, a future that is +/// constructed and immediately dropped emits nothing. Once armed, dropping it +/// while awaiting SQLx records `cancelled`, duration, and the balanced waiter +/// decrement. +struct PoolAcquireAttempt { + pair: PoolOperation, + started: Instant, + emit_legacy: bool, + terminal: bool, +} + +impl PoolAcquireAttempt { + fn start(pair: PoolOperation, emit_legacy: bool) -> Self { + { + let mut waiters = POOL_WAITERS[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *waiters += 1; + publish_waiters(pair, *waiters); + } + Self { + pair, + started: Instant::now(), + emit_legacy, + terminal: false, + } + } + + fn finish(mut self, outcome: Outcome) { + self.terminal = true; + record_pool_acquire(self.pair, outcome, self.started.elapsed(), self.emit_legacy); + self.release_waiter(); + } + + fn release_waiter(&self) { + let mut waiters = POOL_WAITERS[self.pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(*waiters > 0, "pool waiter balance underflow"); + *waiters = waiters.saturating_sub(1); + publish_waiters(self.pair, *waiters); + } +} + +impl Drop for PoolAcquireAttempt { + fn drop(&mut self) { + if !self.terminal { + record_pool_acquire( + self.pair, + Outcome::Cancelled, + self.started.elapsed(), + self.emit_legacy, + ); + self.release_waiter(); + self.terminal = true; + } + } +} + +async fn acquire( pool: &sqlx::PgPool, - role: PoolRole, + pair: PoolOperation, + emit_legacy: bool, ) -> sqlx::Result> { - let started = Instant::now(); + let attempt = PoolAcquireAttempt::start(pair, emit_legacy); let result = pool.acquire().await; let outcome = result .as_ref() .map(|_| Outcome::Success) .unwrap_or_else(Outcome::from_sqlx_error); - record_pool_acquire(role, outcome, started.elapsed()); + attempt.finish(outcome); result } +/// Acquire from an authoritative writer pool for one valid writer operation. +pub(crate) async fn acquire_writer( + pool: &sqlx::PgPool, + operation: WriterOperation, +) -> sqlx::Result> { + acquire(pool, operation.pair(), false).await +} + +/// Acquire from a writer seam already covered by the pre-operation metric. +pub(crate) async fn acquire_writer_with_legacy_metrics( + pool: &sqlx::PgPool, + operation: WriterOperation, +) -> sqlx::Result> { + acquire(pool, operation.pair(), true).await +} + +/// Acquire from a reader seam already covered by the pre-operation metric. +pub(super) async fn acquire_reader_with_legacy_metrics( + pool: &sqlx::PgPool, + operation: ReaderOperation, +) -> sqlx::Result> { + acquire(pool, operation.pair(), true).await +} + +/// Acquire within an operation-owned absolute deadline. +/// +/// A deadline expiry is a timeout terminal. Dropping the enclosing future +/// before that deadline remains a cancellation terminal. +pub(crate) async fn acquire_writer_until( + pool: &sqlx::PgPool, + operation: WriterOperation, + deadline: tokio::time::Instant, +) -> sqlx::Result> { + let pair = operation.pair(); + let attempt = PoolAcquireAttempt::start(pair, false); + match tokio::time::timeout_at(deadline, pool.acquire()).await { + Err(_) => { + attempt.finish(Outcome::Timeout); + Err(sqlx::Error::PoolTimedOut) + } + Ok(result) => { + let outcome = result + .as_ref() + .map(|_| Outcome::Success) + .unwrap_or_else(Outcome::from_sqlx_error); + attempt.finish(outcome); + result + } + } +} + pub(crate) async fn begin_transaction( pool: &sqlx::PgPool, operation: TransactionOperation, ) -> sqlx::Result<(sqlx::Transaction<'static, sqlx::Postgres>, TransactionTimer)> { - let connection = acquire(pool, PoolRole::Writer).await?; + let connection = acquire_writer_with_legacy_metrics(pool, operation.writer_operation()).await?; let transaction = sqlx::Transaction::begin(connection, None).await?; Ok((transaction, TransactionTimer::start(operation))) } @@ -221,16 +567,44 @@ impl Drop for TransactionTimer { #[cfg(test)] mod tests { use super::{ - acquire, observe_advisory_lock, record_pool_acquire, LockType, Outcome, PoolRole, - TransactionOperation, TransactionTimer, + acquire_reader_with_legacy_metrics, acquire_writer, acquire_writer_with_legacy_metrics, + observe_advisory_lock, record_pool_acquire, refresh_pool_waiters, LockType, Outcome, + PoolAcquireAttempt, PoolOperation, ReaderOperation, TransactionOperation, TransactionTimer, + WriterOperation, }; use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use std::collections::{BTreeMap, BTreeSet}; + use std::sync::{Arc, Barrier}; use std::time::Duration; #[test] fn label_vocabularies_are_closed_and_documented() { - assert_eq!(PoolRole::ALL.map(PoolRole::as_str), ["writer", "reader"]); + assert_eq!( + PoolOperation::ALL.map(|pair| (pair.pool_role(), pair.operation())), + super::POOL_ACQUIRE_VALID_PAIRS + ); + assert_eq!( + WriterOperation::ALL.map(WriterOperation::pair), + [ + PoolOperation::WriterBootstrap, + PoolOperation::WriterReadiness, + PoolOperation::WriterTenantResolution, + PoolOperation::WriterAuthentication, + PoolOperation::WriterAuthorization, + PoolOperation::WriterSubscriptionHistory, + PoolOperation::WriterEventWrite, + PoolOperation::WriterMaintenance, + ] + ); + assert_eq!( + ReaderOperation::ALL.map(ReaderOperation::pair), + [ + PoolOperation::ReaderBootstrap, + PoolOperation::ReaderAuthorization, + PoolOperation::ReaderSubscriptionHistory, + ] + ); + assert_eq!(super::POOL_ACQUIRE_RAW_SERIES_PER_POD, 187); assert_eq!( LockType::ALL.map(LockType::as_str), [ @@ -243,7 +617,7 @@ mod tests { ); assert_eq!( Outcome::ALL.map(Outcome::as_str), - ["success", "error", "timeout"] + ["success", "error", "timeout", "cancelled"] ); assert_eq!( TransactionOperation::ALL.map(TransactionOperation::as_str), @@ -311,14 +685,16 @@ mod tests { let _guard = metrics::set_default_local_recorder(&recorder); record_pool_acquire( - PoolRole::Writer, + PoolOperation::WriterReadiness, Outcome::Success, Duration::from_millis(12), + false, ); record_pool_acquire( - PoolRole::Reader, + PoolOperation::ReaderSubscriptionHistory, Outcome::Timeout, Duration::from_millis(34), + true, ); let lock_ok: sqlx::Result<()> = observe_advisory_lock(LockType::Replacement, async { Ok(()) }).await; @@ -353,18 +729,10 @@ mod tests { .collect::>(); for expected in [ - ( - "buzz_db_pool_acquire_wait_seconds", - [("outcome", "success"), ("pool_role", "writer")], - ), ( "buzz_db_pool_acquire_wait_seconds", [("outcome", "timeout"), ("pool_role", "reader")], ), - ( - "buzz_db_pool_acquisitions_total", - [("outcome", "success"), ("pool_role", "writer")], - ), ( "buzz_db_pool_acquisitions_total", [("outcome", "timeout"), ("pool_role", "reader")], @@ -410,6 +778,65 @@ mod tests { "missing metric series {expected:?}; got {keys:?}" ); } + for name in [ + "buzz_db_pool_acquire_wait_seconds", + "buzz_db_pool_acquisitions_total", + ] { + assert!( + !keys.contains(&( + name.to_owned(), + [ + ("outcome".to_owned(), "success".to_owned()), + ("pool_role".to_owned(), "writer".to_owned()), + ] + .into_iter() + .collect(), + )), + "newly instrumented seams must not expand legacy metric population" + ); + } + + for (name, labels) in [ + ( + "buzz_db_pool_acquire_duration_seconds", + [("operation", "readiness"), ("pool_role", "writer")], + ), + ( + "buzz_db_pool_acquire_duration_seconds", + [ + ("operation", "subscription_history"), + ("pool_role", "reader"), + ], + ), + ] { + let labels = labels + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(); + assert!( + keys.contains(&(name.to_owned(), labels)), + "missing operation-aware pool duration for {name}" + ); + } + for (pool_role, operation, outcome) in [ + ("writer", "readiness", "success"), + ("reader", "subscription_history", "timeout"), + ] { + assert!(keys.contains(&( + "buzz_db_pool_acquire_attempts_total".to_owned(), + [ + ("operation".to_owned(), operation.to_owned()), + ("outcome".to_owned(), outcome.to_owned()), + ("pool_role".to_owned(), pool_role.to_owned()), + ] + .into_iter() + .collect(), + ))); + } + assert!(keys.iter().all(|(name, labels)| { + name != "buzz_db_pool_acquire_duration_seconds" + || (!labels.contains_key("outcome") && !labels.contains_key("result")) + })); for (key, _, _, value) in snapshot { if key.key().name().ends_with("_seconds") { @@ -426,43 +853,391 @@ mod tests { } } + #[test] + fn cancelled_attempt_records_terminal_and_refreshes_zero() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let attempt = PoolAcquireAttempt::start(PoolOperation::WriterTenantResolution, false); + drop(attempt); + refresh_pool_waiters(true); + + let mut saw_cancelled = false; + let mut saw_zero = false; + for (key, _, _, value) in snapshotter.snapshot().into_vec() { + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + if labels.get("operation") != Some(&"tenant_resolution") { + continue; + } + match key.key().name() { + "buzz_db_pool_acquire_attempts_total" => { + let DebugValue::Counter(value) = value else { + panic!("attempt terminals must be a counter"); + }; + saw_cancelled = labels.get("outcome") == Some(&"cancelled") && value == 1; + } + "buzz_db_pool_waiters" => { + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be a gauge"); + }; + saw_zero = value.into_inner() == 0.0; + } + _ => {} + } + } + assert!( + saw_cancelled, + "dropped armed attempt must terminalize cancellation" + ); + assert!(saw_zero, "periodic refresh must publish a healthy zero"); + } + + #[test] + fn waiter_refresh_omits_reader_pairs_when_no_reader_pool_is_configured() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + refresh_pool_waiters(false); + + let published = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_waiters" { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be gauges"); + }; + assert_eq!(value.into_inner(), 0.0); + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + Some((labels["pool_role"].clone(), labels["operation"].clone())) + }) + .collect::>(); + let expected = WriterOperation::ALL + .into_iter() + .map(|operation| { + let pair = operation.pair(); + (pair.pool_role().to_owned(), pair.operation().to_owned()) + }) + .collect::>(); + + assert_eq!(published, expected); + assert!(published.iter().all(|(pool_role, _)| pool_role == "writer")); + } + #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] + async fn compatibility_metrics_only_cover_preexisting_acquisition_seams() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.lock().await; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy(&crate::test_support::database_url()) + .expect("construct lazy compatibility test pool"); + pool.close().await; + + let error = acquire_writer(&pool, WriterOperation::EventWrite) + .await + .expect_err("closed newly instrumented seam errors"); + assert!(matches!(error, sqlx::Error::PoolClosed)); + assert_eq!( + legacy_acquisition_count(&snapshotter.snapshot().into_vec()), + 0 + ); + + let error = acquire_writer_with_legacy_metrics(&pool, WriterOperation::EventWrite) + .await + .expect_err("closed legacy seam errors"); + assert!(matches!(error, sqlx::Error::PoolClosed)); + assert_eq!( + legacy_acquisition_count(&snapshotter.snapshot().into_vec()), + 1 + ); + } + + fn legacy_acquisition_count( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )], + ) -> u64 { + snapshot + .iter() + .filter_map(|(key, _, _, value)| { + (key.key().name() == "buzz_db_pool_acquisitions_total") + .then_some(value) + .map(|value| match value { + DebugValue::Counter(value) => *value, + _ => panic!("legacy acquisitions must be a counter"), + }) + }) + .sum() + } + + #[test] + fn concurrent_attempts_publish_an_exact_balanced_waiter_count() { + const ATTEMPTS: usize = 8; + + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let armed = Arc::new(Barrier::new(ATTEMPTS + 1)); + let release = Arc::new(Barrier::new(ATTEMPTS + 1)); + let threads = (0..ATTEMPTS) + .map(|_| { + let armed = Arc::clone(&armed); + let release = Arc::clone(&release); + std::thread::spawn(move || { + let attempt = + PoolAcquireAttempt::start(PoolOperation::WriterTenantResolution, false); + armed.wait(); + release.wait(); + drop(attempt); + }) + }) + .collect::>(); + + armed.wait(); + refresh_pool_waiters(true); + let live = waiter_value( + &snapshotter.snapshot().into_vec(), + "writer", + "tenant_resolution", + ); + assert_eq!(live, Some(ATTEMPTS as f64)); + + release.wait(); + for thread in threads { + thread.join().expect("waiter thread completes"); + } + refresh_pool_waiters(true); + let balanced = waiter_value( + &snapshotter.snapshot().into_vec(), + "writer", + "tenant_resolution", + ); + assert_eq!(balanced, Some(0.0)); + } + + #[test] + fn waiter_publication_is_serialized_with_state_mutation() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); + let pair = PoolOperation::WriterTenantResolution; + let first = PoolAcquireAttempt::start(pair, false); + let second = PoolAcquireAttempt::start(pair, false); + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + *super::WAITER_PUBLISH_TEST_HOOK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(super::WaiterPublishTestHook { + pair, + value: 1, + entered: Arc::clone(&entered), + release: Arc::clone(&release), + armed: Arc::new(std::sync::atomic::AtomicBool::new(true)), + }); + + let first_drop = std::thread::spawn(move || drop(first)); + entered.wait(); + let mutation_lock_held = super::POOL_WAITERS[pair.index()].try_lock().is_err(); + release.wait(); + first_drop.join().expect("first drop completes"); + drop(second); + *super::WAITER_PUBLISH_TEST_HOOK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + + assert!( + mutation_lock_held, + "waiter state mutation must remain locked until its publication completes" + ); + assert_eq!( + *super::WAITER_LAST_PUBLISHED[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + 0, + "the final directly published waiter value must be balanced without a refresh" + ); + } + + fn waiter_value( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )], + pool_role: &str, + operation: &str, + ) -> Option { + snapshot.iter().find_map(|(key, _, _, value)| { + let labels = key.key().labels().collect::>(); + if key.key().name() != "buzz_db_pool_waiters" + || !labels + .iter() + .any(|label| label.key() == "pool_role" && label.value() == pool_role) + || !labels + .iter() + .any(|label| label.key() == "operation" && label.value() == operation) + { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be gauges"); + }; + Some(value.into_inner()) + }) + } + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { - let database_url = std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials + // This timeout also bounds the pool's initial connection. Leave enough + // headroom for a cold PostgreSQL start under the lane's eight workers; + // the assertion below cares about classification, not a sub-second + // synthetic timeout budget. + let database_url = crate::test_support::database_url(); let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(1) - .acquire_timeout(Duration::from_millis(75)) + .acquire_timeout(Duration::from_secs(5)) .connect(&database_url) .await .expect("connect size-one test pool"); + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect(&database_url) + .await + .expect("connect size-one reader test pool"); let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); let _guard = metrics::set_default_local_recorder(&recorder); - let held = acquire(&pool, PoolRole::Writer) + let held = acquire_writer_with_legacy_metrics(&pool, WriterOperation::EventWrite) .await .expect("writer acquire succeeds"); - let timeout = acquire(&pool, PoolRole::Reader) + let mut cancelled = Box::pin(acquire_writer_with_legacy_metrics( + &pool, + WriterOperation::Authentication, + )); + tokio::select! { + result = &mut cancelled => panic!("blocked acquisition unexpectedly completed: {result:?}"), + () = tokio::time::sleep(Duration::from_millis(40)) => {} + } + let before_cancel = snapshotter.snapshot().into_vec(); + let live_waiter = before_cancel.iter().find_map(|(key, _, _, value)| { + let labels = key.key().labels().collect::>(); + if key.key().name() != "buzz_db_pool_waiters" + || !labels + .iter() + .any(|label| label.key() == "pool_role" && label.value() == "writer") + || !labels + .iter() + .any(|label| label.key() == "operation" && label.value() == "authentication") + { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be a gauge"); + }; + Some(value.into_inner()) + }); + assert_eq!(live_waiter, Some(1.0)); + let legacy_before_cancel = legacy_acquisition_count(&before_cancel); + assert_eq!( + legacy_before_cancel, 1, + "the completed legacy acquisition must be counted exactly once" + ); + let writer_success = before_cancel.iter().any(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_wait_seconds" { + return false; + } + let labels = key.key().labels().collect::>(); + let DebugValue::Histogram(samples) = value else { + panic!("pool wait must be a histogram"); + }; + labels + .iter() + .any(|label| label.key() == "pool_role" && label.value() == "writer") + && labels + .iter() + .any(|label| label.key() == "outcome" && label.value() == "success") + && !samples.is_empty() + }); + drop(cancelled); + let after_cancel = snapshotter.snapshot().into_vec(); + let legacy_after_cancel = legacy_acquisition_count(&after_cancel); + let mut cancelled_terminal = None; + let mut balanced_waiter = None; + for (key, _, _, value) in after_cancel { + let labels = key.key().labels().collect::>(); + let label = |name: &str| { + labels + .iter() + .find(|label| label.key() == name) + .map(|label| label.value()) + }; + if label("pool_role") != Some("writer") || label("operation") != Some("authentication") + { + continue; + } + match key.key().name() { + "buzz_db_pool_waiters" => { + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be a gauge"); + }; + balanced_waiter = Some(value.into_inner()); + } + "buzz_db_pool_acquire_attempts_total" if label("outcome") == Some("cancelled") => { + let DebugValue::Counter(value) = value else { + panic!("cancelled acquisition terminal must be a counter"); + }; + cancelled_terminal = Some(value); + } + _ => {} + } + } + assert_eq!(balanced_waiter, Some(0.0)); + assert_eq!(cancelled_terminal, Some(1)); + assert_eq!( + legacy_after_cancel, 0, + "cancelling a legacy seam must not expand its historical population" + ); + let held_reader = reader_pool + .acquire() .await - .expect_err("reader-labeled checkout times out while pool is saturated"); + .expect("hold the reader test connection"); + let timeout = + acquire_reader_with_legacy_metrics(&reader_pool, ReaderOperation::SubscriptionHistory) + .await + .expect_err("reader-labeled checkout times out while pool is saturated"); assert!(matches!(timeout, sqlx::Error::PoolTimedOut)); + drop(held_reader); drop(held); pool.close().await; - let closed = acquire(&pool, PoolRole::Writer) + let closed = acquire_writer_with_legacy_metrics(&pool, WriterOperation::Readiness) .await .expect_err("closed pool acquire errors"); assert!(matches!(closed, sqlx::Error::PoolClosed)); let mut outcomes = BTreeMap::<(String, String), Vec>::new(); for (key, _, _, value) in snapshotter.snapshot().into_vec() { - if key.key().name() != "buzz_db_pool_acquire_wait_seconds" { - continue; - } - let DebugValue::Histogram(samples) = value else { - panic!("pool wait must be a histogram"); - }; let labels = key.key().labels().collect::>(); let label = |name: &str| { labels @@ -471,15 +1246,27 @@ mod tests { .map(|label| label.value().to_owned()) .unwrap_or_default() }; - outcomes.insert( - (label("pool_role"), label("outcome")), - samples - .into_iter() - .map(|sample| sample.into_inner()) - .collect(), - ); + if key.key().name() == "buzz_db_pool_acquire_wait_seconds" { + let DebugValue::Histogram(samples) = value else { + panic!("pool wait must be a histogram"); + }; + if samples.is_empty() { + continue; + } + outcomes.insert( + (label("pool_role"), label("outcome")), + samples + .into_iter() + .map(|sample| sample.into_inner()) + .collect(), + ); + } } - assert!(outcomes.contains_key(&("writer".to_owned(), "success".to_owned()))); + assert!(writer_success); + assert!( + !outcomes.contains_key(&("writer".to_owned(), "cancelled".to_owned())), + "legacy compatibility families must not add a cancellation population" + ); assert!(outcomes.contains_key(&("writer".to_owned(), "error".to_owned()))); let timeout_samples = outcomes .get(&("reader".to_owned(), "timeout".to_owned())) @@ -490,11 +1277,395 @@ mod tests { ); } - #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] + async fn deletion_catalog_readiness_records_timeout_and_recovers() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.lock().await; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect(&crate::test_support::database_url()) + .await + .expect("connect size-one deletion readiness pool"); + let db = crate::Db::from_pool(pool.clone()); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let held = pool.acquire().await.expect("hold the only pool connection"); + let timeout = db + .validate_deletion_serving_catalog_for_readiness( + tokio::time::Instant::now() + Duration::from_millis(40), + ) + .await + .expect_err("saturated deletion catalog checkout must time out"); + assert!(matches!( + timeout, + crate::DbError::Sqlx(sqlx::Error::PoolTimedOut) + )); + drop(held); + db.validate_deletion_serving_catalog_for_readiness( + tokio::time::Instant::now() + Duration::from_secs(2), + ) + .await + .expect("deletion catalog readiness must recover after pool release"); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!( + waiter_value(&snapshot, "writer", "readiness"), + Some(0.0), + "deadline terminal must directly balance the readiness waiter" + ); + for outcome in ["timeout", "success"] { + assert!( + snapshot.iter().any(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_attempts_total" { + return false; + } + let labels = key.key().labels().collect::>(); + let has = |name: &str, expected: &str| { + labels + .iter() + .any(|label| label.key() == name && label.value() == expected) + }; + has("pool_role", "writer") + && has("operation", "readiness") + && has("outcome", outcome) + && matches!(value, DebugValue::Counter(1)) + }), + "missing writer/readiness/{outcome} acquisition terminal" + ); + } + } + + async fn production_db_methods_emit_exact_pool_operation_labels() { + use buzz_core::CommunityId; + use chrono::Utc; + use uuid::Uuid; + + let database_url = crate::test_support::database_url(); + let writer_pool = crate::Db::connect_writer_pool(&crate::DbConfig { + database_url: database_url.clone(), + max_connections: 4, + min_connections: 0, + acquire_timeout_secs: 5, + ..crate::DbConfig::default() + }) + .await + .expect("connect production-method writer pool"); + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .acquire_timeout(Duration::from_secs(5)) + .connect(&database_url) + .await + .expect("connect production-method reader pool"); + let writer_db = crate::Db::from_pool(writer_pool.clone()); + let mut routed_db = crate::Db::from_pools(writer_pool.clone(), reader_pool); + routed_db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5))); + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let test_scope = CommunityId::from_uuid(Uuid::new_v4()); + let query = crate::EventQuery::for_community(test_scope); + + routed_db.read_pool_boot_ping_for_tests().await; + routed_db + .verify_replica_fence_at_boot() + .await + .expect("real startup fence verification succeeds"); + let _ = crate::replica_fence::probe_once(&writer_pool, routed_db.fence()).await; + routed_db.fence().force_open_for_tests(Utc::now()); + assert_eq!( + writer_db + .readiness_check(tokio::time::Instant::now() + Duration::from_secs(1)) + .await, + crate::DbReadinessOutcome::Success + ); + let _ = writer_db + .lookup_community_by_host("pool-operation-matrix.invalid") + .await; + let _ = writer_db + .lookup_community_by_host_for_management("pool-operation-matrix.invalid") + .await; + let _ = writer_db.list_communities_owned_by(&"a".repeat(64)).await; + let _ = writer_db.lookup_community_host(test_scope).await; + let _ = writer_db + .set_community_icon(test_scope, Some("pool-operation-matrix")) + .await; + let _ = writer_db + .create_community_with_owner( + &format!("pool-operation-matrix-{}.invalid", Uuid::new_v4().simple()), + &"b".repeat(64), + ) + .await; + let _ = writer_db + .archive_community_owned_by( + "pool-operation-matrix.invalid", + &"c".repeat(64), + "protected.invalid", + ) + .await; + let _ = writer_db + .unarchive_community_owned_by("pool-operation-matrix.invalid", &"c".repeat(64)) + .await; + let _ = writer_db.community_of_channel(Uuid::new_v4()).await; + let _ = writer_db.communities_of_channels(&[Uuid::new_v4()]).await; + let _ = writer_db + .ensure_user_for_authorization(test_scope, &[17; 32]) + .await; + let _ = writer_db + .set_agent_owner_for_authorization(test_scope, &[18; 32], &[19; 32]) + .await; + let _ = writer_db.is_pubkey_allowed(test_scope, &[7; 32]).await; + let _ = writer_db + .is_agent_owner(test_scope, &[8; 32], &[9; 32]) + .await; + let _ = writer_db + .moderation_restriction_state(test_scope, &[14; 32]) + .await; + let _ = writer_db + .get_agent_channel_policy(test_scope, &[15; 32]) + .await; + let _ = writer_db + .get_thread_metadata_by_event(test_scope, &[10; 32]) + .await; + let _ = writer_db.get_thread_summary(test_scope, &[16; 32]).await; + let _ = writer_db + .get_channel_for_event_write(test_scope, Uuid::new_v4()) + .await; + let _ = writer_db + .get_members_for_event_write(test_scope, Uuid::new_v4()) + .await; + let _ = writer_db + .get_users_bulk_for_event_write(test_scope, &[vec![11; 32]]) + .await; + let _ = writer_db + .huddle_started_link_exists_for_event_write( + test_scope, + Uuid::new_v4(), + Uuid::new_v4(), + &[12; 32], + ) + .await; + let _ = writer_db + .huddle_started_link_exists(test_scope, Uuid::new_v4(), Uuid::new_v4(), &[13; 32]) + .await; + let _ = writer_db.list_archived(test_scope).await; + let _ = writer_db + .query_events_routed("pool_operation_matrix_writer", &query) + .await; + let write_tx = writer_db + .begin_event_write_transaction() + .await + .expect("event-write semantic entry point begins a real transaction"); + write_tx + .rollback() + .await + .expect("rollback operation-label fixture"); + let _ = writer_db + .is_community_active_for_maintenance(test_scope) + .await; + let _ = writer_db.usage_community_count().await; + let _ = writer_db.reap_expired_ephemeral_channels().await; + let deletion_store = writer_db.deletion_store(); + let _ = deletion_store.reap_expired_serving_write_leases(1).await; + let _ = deletion_store.serving_lease_stats().await; + let _ = routed_db.is_relay_member(test_scope, &"a".repeat(64)).await; + let _ = routed_db + .query_events_routed("pool_operation_matrix_reader", &query) + .await; + routed_db.refresh_pool_waiter_metrics(); + + let snapshot = snapshotter.snapshot().into_vec(); + let attempt_labels = snapshot + .iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_attempts_total" { + return None; + } + let DebugValue::Counter(value) = value else { + panic!("acquisition attempts must be counters"); + }; + if *value == 0 { + return None; + } + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + assert_eq!(labels.get("outcome").map(String::as_str), Some("success")); + Some((labels["pool_role"].clone(), labels["operation"].clone())) + }) + .collect::>(); + let expected = super::POOL_ACQUIRE_VALID_PAIRS + .into_iter() + .map(|(pool_role, operation)| (pool_role.to_owned(), operation.to_owned())) + .collect::>(); + assert_eq!( + attempt_labels, expected, + "real production Db/store methods must emit every exact valid operation pair" + ); + + let duration_labels = snapshot + .iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_duration_seconds" { + return None; + } + let DebugValue::Histogram(samples) = value else { + panic!("acquisition duration must be a histogram"); + }; + assert!(!samples.is_empty()); + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + assert!(!labels.contains_key("outcome")); + Some(( + labels["pool_role"].to_owned(), + labels["operation"].to_owned(), + )) + }) + .collect::>(); + assert_eq!(duration_labels, expected); + + let waiter_labels = snapshot + .iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_waiters" { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be gauges"); + }; + assert_eq!(value.into_inner(), 0.0); + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + Some(( + labels["pool_role"].to_owned(), + labels["operation"].to_owned(), + )) + }) + .collect::>(); + assert_eq!(waiter_labels, expected); + } + + async fn serving_write_gate_records_cancel_timeout_success_and_recovery() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.lock().await; + let database_url = crate::test_support::database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + // The same budget also covers the pool's initial physical + // connection. Keep enough headroom for a cold CI database; the + // held size-one connection below still deterministically drives + // the checkout timeout terminal. + .acquire_timeout(Duration::from_secs(1)) + .connect(&database_url) + .await + .expect("connect size-one serving-write test pool"); + let db = crate::Db::from_pool(pool.clone()); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate serving-write test DB"); + } + let test_scope = db + .ensure_configured_community(&format!( + "pool-observability-{}.example", + uuid::Uuid::new_v4().simple() + )) + .await + .expect("create serving-write test community") + .id; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let held = pool.acquire().await.expect("hold sole writer connection"); + + let store = db.deletion_store(); + let mut cancelled = Box::pin(store.is_serving_active(test_scope)); + tokio::select! { + result = &mut cancelled => panic!("blocked serving-write gate unexpectedly completed: {result:?}"), + () = tokio::time::sleep(Duration::from_millis(25)) => {} + } + assert_eq!( + waiter_value(&snapshotter.snapshot().into_vec(), "writer", "event_write"), + Some(1.0) + ); + drop(cancelled); + + let timeout = store + .is_serving_active(test_scope) + .await + .expect_err("saturated serving-write gate times out"); + assert!(matches!( + timeout, + crate::DbError::Sqlx(sqlx::Error::PoolTimedOut) + )); + drop(held); + + assert!(store + .is_serving_active(test_scope) + .await + .expect("serving-write gate recovers after release")); + let lease = store + .acquire_serving_write_lease( + test_scope, + "pool_observability", + "pool-observability-test", + Duration::from_secs(5), + ) + .await + .expect("serving-write lease acquires through event-write seam"); + assert!(store + .release_serving_write_lease(&lease) + .await + .expect("serving-write lease release")); + refresh_pool_waiters(false); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!(waiter_value(&snapshot, "writer", "event_write"), Some(0.0)); + assert_eq!(attempt_count(&snapshot, "event_write", "cancelled"), 1); + assert_eq!(attempt_count(&snapshot, "event_write", "timeout"), 1); + assert!( + attempt_count(&snapshot, "event_write", "success") >= 3, + "gate recovery plus lease acquire/release must emit successes" + ); + } + + fn attempt_count( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )], + operation: &str, + outcome: &str, + ) -> u64 { + snapshot + .iter() + .find_map(|(key, _, _, value)| { + let labels = key.key().labels().collect::>(); + (key.key().name() == "buzz_db_pool_acquire_attempts_total" + && labels + .iter() + .any(|label| label.key() == "operation" && label.value() == operation) + && labels + .iter() + .any(|label| label.key() == "outcome" && label.value() == outcome)) + .then(|| match value { + DebugValue::Counter(value) => *value, + _ => panic!("pool attempts must be a counter"), + }) + }) + .unwrap_or(0) + } + async fn advisory_lock_records_success_contention_timeout_and_error() { - let database_url = std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials + let database_url = crate::test_support::database_url(); let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(4) .connect(&database_url) @@ -633,4 +1804,36 @@ mod tests { "lock timer must include the holder wait: {contention:?}" ); } + + mod postgres_tests { + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + super::pool_acquire_records_success_timeout_and_error_with_wait_time().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn production_db_methods_emit_exact_pool_operation_labels() { + super::production_db_methods_emit_exact_pool_operation_labels().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn deletion_catalog_readiness_records_timeout_and_recovers() { + super::deletion_catalog_readiness_records_timeout_and_recovers().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn serving_write_gate_records_cancel_timeout_success_and_recovery() { + super::serving_write_gate_records_cancel_timeout_success_and_recovery().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn advisory_lock_records_success_contention_timeout_and_error() { + super::advisory_lock_records_success_contention_timeout_and_error().await; + } + } } diff --git a/crates/buzz-db/src/runtime/replica_fence.rs b/crates/buzz-db/src/runtime/replica_fence.rs index cf9b46ddd8b..044dc3a58c6 100644 --- a/crates/buzz-db/src/runtime/replica_fence.rs +++ b/crates/buzz-db/src/runtime/replica_fence.rs @@ -395,7 +395,12 @@ pub async fn verify_floor_guard_behavior(pool: &PgPool) -> crate::Result<()> { } }; - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 1. Pool arming (Perci: assert the effective value, not the intent). let armed: String = sqlx::query_scalar("SHOW buzz.created_at_floor") @@ -552,7 +557,11 @@ pub enum ProbeError { /// a single SELECT would not guarantee evaluation order across the /// subexpressions, reopening the race this ordering exists to close. async fn sample_writer(writer: &PgPool) -> Result { - let mut conn = writer.acquire().await?; + let mut conn = crate::observability::acquire_writer( + writer, + crate::observability::WriterOperation::Maintenance, + ) + .await?; // 1. S first. let sampled_at: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") @@ -671,11 +680,16 @@ pub async fn probe_once(writer: &PgPool, fence: &ReplicaFence) -> Result) { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - - fn test_db_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) - } - /// A private scratch database with migrations applied: the probe tests /// mutate the singleton heartbeat row (rewind/rotate), which must never /// race the shared dev database or each other. async fn scratch_db() -> (PgPool, PgPool, String) { - let admin = PgPool::connect(&test_db_url()) + let admin = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect admin"); let name = format!("fence_probe_{}", uuid::Uuid::new_v4().simple()); @@ -810,7 +818,7 @@ mod tests { .execute(&admin) .await .expect("create scratch db"); - let base = test_db_url(); + let base = crate::test_support::database_url(); let idx = base.rfind('/').expect("db url has a path segment"); let pool = PgPool::connect(&format!("{}/{}", &base[..idx], name)) .await @@ -973,17 +981,27 @@ mod tests { /// sessions, per the agreed classification. #[tokio::test] #[ignore = "requires Postgres"] - async fn sample_writer_sees_open_transactions_and_ignores_idle() { - let pool = PgPool::connect(&test_db_url()).await.expect("connect"); + async fn migration_schema_cluster_global_sample_writer_sees_open_transactions_and_ignores_idle() + { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); + crate::migration::run_migrations(&pool) + .await + .expect("apply migration schema"); // A plain idle session: pinned connection, no transaction. - let idle_pool = PgPool::connect(&test_db_url()).await.expect("connect idle"); + let idle_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect idle"); let _idle_conn = idle_pool.acquire().await.expect("idle conn"); let before = sample_writer(&pool).await.expect("sample without tx"); // Now hold a transaction open on a second connection. - let tx_pool = PgPool::connect(&test_db_url()).await.expect("connect tx"); + let tx_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect tx"); let mut tx = tx_pool.begin().await.expect("begin"); sqlx::query("SELECT 1") .execute(&mut *tx) @@ -1016,12 +1034,16 @@ mod tests { /// never silently `MIN()` the hidden row away. #[tokio::test] #[ignore = "requires Postgres"] - async fn sample_writer_fails_closed_when_activity_is_masked() { - let admin = PgPool::connect(&test_db_url()).await.expect("connect"); + async fn cluster_global_sample_writer_fails_closed_when_activity_is_masked() { + let admin = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); // Hold a transaction open as the privileged user: this is the row // the unprivileged probe must notice it cannot classify. - let tx_pool = PgPool::connect(&test_db_url()).await.expect("connect tx"); + let tx_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect tx"); let mut tx = tx_pool.begin().await.expect("begin"); sqlx::query("SELECT 1") .execute(&mut *tx) @@ -1038,7 +1060,7 @@ mod tests { .await .expect("create unprivileged role"); - let base = test_db_url(); + let base = crate::test_support::database_url(); let unpriv_url = { let rest = base.strip_prefix("postgres://").expect("pg url"); let at = rest.rfind('@').expect("credentials in url"); @@ -1079,7 +1101,9 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn aurora_identity_probe_reports_false_on_plain_postgres() { - let pool = PgPool::connect(&test_db_url()).await.expect("connect"); + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); let mut conn = pool.acquire().await.expect("conn"); assert!( !reader_supports_aurora_identity(&mut conn) @@ -1100,7 +1124,7 @@ mod tests { /// same database observes a token/epoch that resolves that entry. #[tokio::test] #[ignore = "requires Postgres"] - async fn probe_commits_tokens_and_sessions_prove_coverage() { + async fn cluster_global_probe_commits_tokens_and_sessions_prove_coverage() { let (admin, pool, name) = scratch_db().await; let fence = ReplicaFence::new(); @@ -1155,7 +1179,7 @@ mod tests { /// epoch — fails the epoch check instead of proving stale coverage. #[tokio::test] #[ignore = "requires Postgres"] - async fn probe_rotates_epoch_on_same_epoch_token_regression() { + async fn cluster_global_probe_rotates_epoch_on_same_epoch_token_regression() { let (admin, pool, name) = scratch_db().await; let fence = ReplicaFence::new(); diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index ecdc983a4ac..d97ebf0ac54 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -1,19 +1,263 @@ use super::*; use crate::{relay_members, thread}; use buzz_core::CommunityId; -use sqlx::PgPool; +use sqlx::{Connection, PgPool}; use uuid::Uuid; -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 -- local test-only credentials async fn setup_db() -> Db { let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") { + crate::migration::run_migrations(&pool) + .await + .expect("apply migration schema"); + } Db::from_pool(pool) } +#[tokio::test] +async fn begin_transaction_compatibility_alias_is_preserved() { + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy(&crate::test_support::database_url()) + .expect("construct lazy compatibility pool"); + pool.close().await; + let db = Db::from_pool(pool); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + #[allow(deprecated)] + let result = db.begin_transaction().await; + assert!(matches!( + result, + Err(DbError::Sqlx(sqlx::Error::PoolClosed)) + )); + + let counters = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| { + let name = key.key().name(); + if ![ + "buzz_db_pool_acquire_attempts_total", + "buzz_db_pool_acquisitions_total", + ] + .contains(&name) + { + return None; + } + let DebugValue::Counter(value) = value else { + panic!("pool acquisition terminals must be counters"); + }; + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + Some(((name.to_owned(), labels), value)) + }) + .collect::>(); + let expected = [ + ( + ( + "buzz_db_pool_acquire_attempts_total".to_owned(), + [ + ("operation".to_owned(), "event_write".to_owned()), + ("outcome".to_owned(), "error".to_owned()), + ("pool_role".to_owned(), "writer".to_owned()), + ] + .into_iter() + .collect(), + ), + 1, + ), + ( + ( + "buzz_db_pool_acquisitions_total".to_owned(), + [ + ("outcome".to_owned(), "error".to_owned()), + ("pool_role".to_owned(), "writer".to_owned()), + ] + .into_iter() + .collect(), + ), + 1, + ), + ] + .into_iter() + .collect::>(); + assert_eq!(counters, expected); +} + +#[test] +fn nip43_reconciliation_compatibility_alias_is_preserved() { + #[allow(deprecated)] + async fn call( + db: &Db, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> crate::Result { + db.nip43_membership_snapshot_needs_reconciliation(community_id, relay_pubkey) + .await + } + + let _ = call; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn readiness_check_distinguishes_pool_exhaustion_from_success() { + let database_url = crate::test_support::database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect size-one readiness test pool"); + let held = pool + .acquire() + .await + .expect("hold the only readiness test connection"); + let db = Db::from_pool(pool); + + let exhausted = db + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_millis(25)) + .await; + assert_eq!(exhausted, DbReadinessOutcome::PoolTimeout); + + drop(held); + let recovered = db + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await; + assert_eq!(recovered, DbReadinessOutcome::Success); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn readiness_check_classifies_closed_pool_query_timeout_and_query_error() { + let database_url = crate::test_support::database_url(); + + let closed_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect closed readiness test pool"); + closed_pool.close().await; + let closed = Db::from_pool(closed_pool) + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await; + assert_eq!(closed, DbReadinessOutcome::PoolError); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect query classification test pool"); + let db = Db::from_pool(pool); + + let timed_out = db + .readiness_check_sql( + tokio::time::Instant::now() + std::time::Duration::from_millis(25), + "SELECT pg_sleep(0.2)", + ) + .await; + assert_eq!(timed_out, DbReadinessOutcome::QueryTimeout); + + let query_error = db + .readiness_check_sql( + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + "SELECT 1 / 0", + ) + .await; + assert_eq!(query_error, DbReadinessOutcome::QueryError); + + assert_eq!( + db.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await, + DbReadinessOutcome::Success, + "query failures must return the acquired connection to the pool" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn readiness_check_cancellation_balances_waiter_and_inflight_connection() { + let database_url = crate::test_support::database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect cancellation readiness test pool"); + let held = pool + .acquire() + .await + .expect("hold sole connection before waiter cancellation"); + let db = Db::from_pool(pool); + + let waiting_db = db.clone(); + let waiting = tokio::spawn(async move { + waiting_db + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(5)) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + waiting.abort(); + assert!(waiting + .await + .expect_err("waiting check must be cancelled") + .is_cancelled()); + drop(held); + + assert_eq!( + db.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await, + DbReadinessOutcome::Success, + "cancelled pool waiter must not consume the released connection" + ); + + let querying_db = db.clone(); + let querying = tokio::spawn(async move { + querying_db + .readiness_check_sql( + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + "SELECT pg_sleep(5)", + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + querying.abort(); + assert!(querying + .await + .expect_err("querying check must be cancelled") + .is_cancelled()); + + let recovered = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let outcome = db + .readiness_check( + tokio::time::Instant::now() + std::time::Duration::from_millis(250), + ) + .await; + match outcome { + DbReadinessOutcome::Success => break outcome, + DbReadinessOutcome::PoolTimeout => tokio::task::yield_now().await, + unexpected => panic!( + "cancelled in-flight query produced unexpected recovery outcome: {unexpected:?}" + ), + } + } + }) + .await + .expect("cancelled in-flight query must return or replace its connection"); + assert_eq!(recovered, DbReadinessOutcome::Success); +} + async fn make_community(pool: &PgPool) -> Uuid { let id = Uuid::new_v4(); let host = format!("communities-of-channels-{}.example", id.simple()); @@ -28,7 +272,7 @@ async fn make_community(pool: &PgPool) -> Uuid { #[tokio::test] #[ignore = "requires Postgres"] -async fn database_guard_covers_legacy_writer_and_nip09_deletion() { +async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; @@ -394,6 +638,98 @@ async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { .await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn push_gateway_profile_migration_converges_brownfield_authority() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin database"); + let (pool, name) = create_scratch_db_through(&admin, "push_profile", Some(42)).await; + let installation_id = Uuid::new_v4(); + let now = chrono::Utc::now(); + + sqlx::query( + "INSERT INTO push_gateway_installations(\ + id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \ + token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \ + VALUES($1, $2, $3, 0, 'buzz-ios-production', $4, $5, 1, $6)", + ) + .bind(installation_id) + .bind(vec![1_u8]) + .bind(vec![2_u8; 33]) + .bind(vec![3_u8]) + .bind(vec![4_u8; 32]) + .bind(now + chrono::Duration::days(1)) + .execute(&pool) + .await + .expect("insert legacy production installation"); + sqlx::query( + "INSERT INTO push_gateway_delegations(\ + id, installation_id, relay_pubkey, endpoint_epoch, generation, not_before, expires_at) \ + VALUES($1, $2, $3, 1, 1, $4, $5)", + ) + .bind(Uuid::new_v4()) + .bind(installation_id) + .bind(vec![5_u8; 32]) + .bind(now) + .bind(now + chrono::Duration::hours(1)) + .execute(&pool) + .await + .expect("insert delegation for legacy installation"); + + migration::run_migrations(&pool) + .await + .expect("apply dogfood-only migration"); + + let legacy_installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count legacy installations"); + let legacy_delegations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") + .fetch_one(&pool) + .await + .expect("count legacy delegations"); + assert_eq!(legacy_installations, 0); + assert_eq!(legacy_delegations, 0); + + sqlx::query( + "INSERT INTO push_gateway_installations(\ + id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \ + token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \ + VALUES($1, $2, $3, 0, 'buzz-ios-dogfood', $4, $5, 1, $6)", + ) + .bind(Uuid::new_v4()) + .bind(vec![6_u8]) + .bind(vec![7_u8; 33]) + .bind(vec![8_u8]) + .bind(vec![9_u8; 32]) + .bind(now + chrono::Duration::days(1)) + .execute(&pool) + .await + .expect("dogfood installation is accepted after migration"); + + let sandbox = sqlx::query( + "INSERT INTO push_gateway_installations(\ + id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \ + token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \ + VALUES($1, $2, $3, 0, 'buzz-ios-sandbox', $4, $5, 1, $6)", + ) + .bind(Uuid::new_v4()) + .bind(vec![10_u8]) + .bind(vec![11_u8; 33]) + .bind(vec![12_u8]) + .bind(vec![13_u8; 32]) + .bind(now + chrono::Duration::days(1)) + .execute(&pool) + .await; + assert!(sandbox.is_err(), "legacy sandbox profile must be rejected"); + + drop_scratch_db(&admin, pool, &name).await; + admin.close().await; +} + /// Insert identical community + channel rows into a database so the same /// (community, channel) ids resolve in both writer and replica. async fn seed_community_channel( @@ -2180,10 +2516,10 @@ async fn created_at_floor_guard_aborts_old_channel_rows_at_commit() { fn writer_pool_safety_hook_is_single_and_composed() { let source = include_str!("mod.rs"); let connect_pool = source - .split("async fn connect_pool") + .split("async fn connect_writer_pool") .nth(1) .and_then(|tail| tail.split("const READER_ACQUIRE_TIMEOUT").next()) - .expect("connect_pool source block"); + .expect("connect_writer_pool source block"); assert_eq!( connect_pool.matches(".after_connect(").count(), 1, @@ -2191,6 +2527,9 @@ fn writer_pool_safety_hook_is_single_and_composed() { ); assert!(connect_pool.contains("buzz.created_at_floor")); assert!(connect_pool.contains("SHOW transaction_isolation")); + assert!(connect_pool.contains("'lock_timeout'")); + assert!(connect_pool.contains("'idle_in_transaction_session_timeout'")); + assert!(connect_pool.contains("'statement_timeout'")); assert!(!connect_pool.contains("arm_floor_guard")); assert!(!connect_pool.contains("_arm_floor_guard")); assert!(!connect_pool.contains("allow(unused_variables)")); @@ -2202,7 +2541,7 @@ fn writer_pool_safety_hook_is_single_and_composed() { .expect("reader pool documentation"); assert!(reader_doc.contains("replica sessions are")); assert!(reader_doc.contains("read-only")); - assert!(!reader_doc.contains("Db::connect_pool")); + assert!(!reader_doc.contains("Db::connect_writer_pool")); } #[tokio::test] @@ -2246,6 +2585,146 @@ async fn writer_pool_rejects_non_read_committed_database_default() { .expect("drop isolation test database"); } +/// Session-timeout environment overrides retain PostgreSQL's `0 = disabled` +/// semantics and ignore invalid values. +#[test] +fn session_timeout_env_overlay_zero_passthrough_and_invalid_fallback() { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK.lock().unwrap(); + let keys = [ + "BUZZ_DB_LOCK_TIMEOUT_MS", + "BUZZ_DB_IDLE_TXN_TIMEOUT_MS", + "BUZZ_DB_STATEMENT_TIMEOUT_MS", + ]; + let previous: Vec<_> = keys.iter().map(std::env::var_os).collect(); + let read = |config: DbConfig| { + ( + config.lock_timeout_ms, + config.idle_txn_timeout_ms, + config.statement_timeout_ms, + ) + }; + + for key in keys { + std::env::remove_var(key); + } + let unset = read(DbConfig::default().with_session_timeouts_from_env()); + + std::env::set_var("BUZZ_DB_LOCK_TIMEOUT_MS", "2000"); + std::env::set_var("BUZZ_DB_IDLE_TXN_TIMEOUT_MS", "30000"); + std::env::set_var("BUZZ_DB_STATEMENT_TIMEOUT_MS", "10000"); + let overridden = read(DbConfig::default().with_session_timeouts_from_env()); + + for key in keys { + std::env::set_var(key, "0"); + } + let zero = read(DbConfig::default().with_session_timeouts_from_env()); + + for key in keys { + std::env::set_var(key, "not-a-number"); + } + let junk = read(DbConfig::default().with_session_timeouts_from_env()); + + for (key, value) in keys.iter().zip(previous) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + + let defaults = (DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_IDLE_TXN_TIMEOUT_MS, 0); + assert_eq!(unset, defaults, "unset env must keep the defaults"); + assert_eq!(overridden, (2000, 30000, 10000)); + assert_eq!(zero, (0, 0, 0), "explicit 0 must disable each timeout"); + assert_eq!(junk, defaults, "junk env must keep the defaults"); +} + +/// The production writer constructor installs all three timeout GUCs, bounds +/// ordinary lock waits, and exempts the intentional migration lock wait. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn session_timeouts_install_through_db_new_and_bound_lock_waits() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, name) = create_scratch_db(&admin, "session_timeouts").await; + seed_pool.close().await; + + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let db = Db::new(&DbConfig { + database_url: scratch_url.clone(), + max_connections: 2, + lock_timeout_ms: 500, + idle_txn_timeout_ms: 60_000, + statement_timeout_ms: 0, + ..DbConfig::default() + }) + .await + .expect("connect Db with session timeouts"); + + let (lock, idle, statement): (String, String, String) = sqlx::query_as( + "SELECT current_setting('lock_timeout'), \ + current_setting('idle_in_transaction_session_timeout'), \ + current_setting('statement_timeout')", + ) + .fetch_one(&db.pool) + .await + .expect("read effective GUCs"); + assert_eq!(lock, "500ms"); + assert_eq!(idle, "1min"); + assert_eq!(statement, "0"); + + let mut holder = db.pool.acquire().await.expect("holder connection"); + sqlx::raw_sql("BEGIN; LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *holder) + .await + .expect("hold relation lock"); + let waited = std::time::Instant::now(); + let mut waiter_txn = db.pool.begin().await.expect("waiter transaction"); + let error = sqlx::query("LOCK TABLE events IN ACCESS SHARE MODE") + .execute(&mut *waiter_txn) + .await + .expect_err("waiter must time out, not park"); + drop(waiter_txn); + let code = match &error { + sqlx::Error::Database(db_error) => db_error.code().map(|code| code.to_string()), + other => panic!("expected database error, got {other:?}"), + }; + assert_eq!(code.as_deref(), Some("55P03")); + assert!(waited.elapsed() < std::time::Duration::from_secs(5)); + + let mut advisory_holder = PgPool::connect(&scratch_url) + .await + .expect("advisory holder pool") + .acquire() + .await + .expect("advisory holder conn") + .detach(); + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(crate::deletion::SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(&mut advisory_holder) + .await + .expect("hold schema advisory lock"); + let release = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + let _ = sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(crate::deletion::SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(&mut advisory_holder) + .await; + let _ = advisory_holder.close().await; + }); + db.migrate() + .await + .expect("migrate must wait out the advisory holder"); + release.await.expect("release task"); + + let _ = sqlx::query("ROLLBACK").execute(&mut *holder).await; + drop(holder); + drop_scratch_db(&admin, db.pool.clone(), &name).await; +} + /// The armed writer pool (`Db::new`) must enforce the floor end-to-end /// through the public insert APIs, and the session GUC must be verifiably /// set on pooled connections. diff --git a/crates/buzz-db/src/store/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs index f38231787bf..c5a7ea542f5 100644 --- a/crates/buzz-db/src/store/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -455,7 +455,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs index 6b213d5cce8..2b72780a621 100644 --- a/crates/buzz-db/src/store/allowlist.rs +++ b/crates/buzz-db/src/store/allowlist.rs @@ -28,12 +28,17 @@ impl Db { /// Check if a pubkey is in the allowlist for `community`. #[datastore_span(name = "is_pubkey_allowed", system = "postgresql")] pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authentication, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2", ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -42,10 +47,15 @@ impl Db { /// Check if the community allowlist has any entries (i.e. is enforcement active). #[datastore_span(name = "has_allowlist_entries", system = "postgresql")] pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authentication, + ) + .await?; let row = sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") .bind(community.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -60,6 +70,11 @@ impl Db { added_by: &[u8], note: Option<&str>, ) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "INSERT INTO pubkey_allowlist (community_id, pubkey, added_by, note) VALUES ($1, $2, $3, $4) \ ON CONFLICT DO NOTHING", @@ -68,7 +83,7 @@ impl Db { .bind(pubkey) .bind(added_by) .bind(note) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -80,11 +95,16 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -92,11 +112,16 @@ impl Db { /// List all pubkeys in the community allowlist. #[datastore_span(name = "list_allowlist", system = "postgresql")] pub async fn list_allowlist(&self, community: CommunityId) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", ) .bind(community.as_uuid()) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await?; let mut out = Vec::with_capacity(rows.len()); @@ -113,7 +138,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/api_token.rs b/crates/buzz-db/src/store/api_token.rs index ec380d9e5e7..41d4dcbad29 100644 --- a/crates/buzz-db/src/store/api_token.rs +++ b/crates/buzz-db/src/store/api_token.rs @@ -606,7 +606,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { //! Row-44 conformance: API token lookups MUST be keyed on //! `(community_id, token_hash)`, not on `token_hash` alone. The storage //! UNIQUE index is a *storage* guarantee; the WHERE clause here is the @@ -625,10 +625,8 @@ mod tests { use crate::{ApiTokenRecord, Db}; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_db() -> Db { - let pool = PgPool::connect(TEST_DB_URL) + let pool = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB"); Db::from_pool(pool) diff --git a/crates/buzz-db/src/store/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs index 810c8c0aa1f..b1636de31a3 100644 --- a/crates/buzz-db/src/store/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -34,11 +34,16 @@ pub struct ArchivedIdentity { /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. pub async fn is_archived(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query("SELECT 1 FROM archived_identities WHERE community_id = $1 AND pubkey = $2") .bind(community_id.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.is_some()) } @@ -59,6 +64,11 @@ pub async fn archive( replaced_by: Option<&str>, request_event_id: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "INSERT INTO archived_identities \ (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ @@ -72,7 +82,7 @@ pub async fn archive( .bind(reason) .bind(replaced_by) .bind(request_event_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -83,11 +93,16 @@ pub async fn archive( /// Returns `true` if a row was deleted, `false` if the identity was not archived /// in that community. pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") .bind(community_id.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -98,12 +113,17 @@ pub async fn list_archived( pool: &PgPool, community_id: CommunityId, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let rows = sqlx::query( "SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \ FROM archived_identities WHERE community_id = $1 ORDER BY archived_at ASC", ) .bind(community_id.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -176,13 +196,11 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index a93ffb36c6a..5e89b1101b6 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -29,6 +29,27 @@ pub use crate::channel_members::{ LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; +async fn begin_event_write_transaction( + pool: &PgPool, +) -> Result> { + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + Ok(sqlx::Transaction::begin(connection, None).await?) +} + +async fn acquire_event_write_connection( + pool: &PgPool, +) -> Result> { + Ok(crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?) +} + /// A channel row as returned from the database. #[derive(Debug, Clone)] pub struct ChannelRecord { @@ -104,7 +125,7 @@ pub async fn create_channel( let id = Uuid::new_v4(); - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; sqlx::query( r#" @@ -197,7 +218,7 @@ pub async fn create_channel_with_id( return Err(DbError::InvalidData("channel name is required".into())); } - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; let rows_affected = sqlx::query( r#" @@ -269,6 +290,22 @@ pub async fn get_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result { + get_channel_with_operation( + pool, + community_id, + channel_id, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn get_channel_with_operation( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let row = sqlx::query( r#" SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, @@ -283,7 +320,7 @@ pub async fn get_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await? .ok_or(DbError::ChannelNotFound(channel_id))?; @@ -334,6 +371,22 @@ pub async fn list_channels( community_id: CommunityId, visibility: Option<&str>, ) -> Result> { + list_channels_with_operation( + pool, + community_id, + visibility, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn list_channels_with_operation( + pool: &PgPool, + community_id: CommunityId, + visibility: Option<&str>, + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let rows = if let Some(vis) = visibility { sqlx::query( r#" @@ -352,7 +405,7 @@ pub async fn list_channels( ) .bind(community_id.as_uuid()) .bind(vis) - .fetch_all(pool) + .fetch_all(&mut *connection) .await? } else { sqlx::query( @@ -371,7 +424,7 @@ pub async fn list_channels( "#, ) .bind(community_id.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await? }; @@ -530,7 +583,7 @@ pub async fn update_channel( // this transition — whose own deadline reset is then the latest word. // Non-TTL updates don't touch the fast path and skip the lock. if updates.ttl_seconds.is_some() { - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") .bind(format!( "buzz_channel_ttl:{}:{}", @@ -545,13 +598,20 @@ pub async fn update_channel( } tx.commit().await?; } else { - let result = q.execute(pool).await?; + let mut connection = acquire_event_write_connection(pool).await?; + let result = q.execute(&mut *connection).await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } } - get_channel(pool, community_id, channel_id).await + get_channel_with_operation( + pool, + community_id, + channel_id, + crate::observability::WriterOperation::EventWrite, + ) + .await } /// Sets the topic for a channel, recording who set it and when. @@ -562,6 +622,7 @@ pub async fn set_topic( topic: &str, set_by: &[u8], ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; let result = sqlx::query( "UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -570,7 +631,7 @@ pub async fn set_topic( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); @@ -586,6 +647,7 @@ pub async fn set_purpose( purpose: &str, set_by: &[u8], ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; let result = sqlx::query( "UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -594,7 +656,7 @@ pub async fn set_purpose( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); @@ -611,13 +673,14 @@ pub async fn archive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -638,7 +701,7 @@ pub async fn archive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(()) @@ -653,13 +716,14 @@ pub async fn unarchive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -682,7 +746,7 @@ pub async fn unarchive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(()) @@ -697,12 +761,13 @@ pub async fn soft_delete_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result { + let mut connection = acquire_event_write_connection(pool).await?; let result = sqlx::query( "UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -714,6 +779,11 @@ pub async fn soft_delete_channel( /// `archived_at IS NULL` guard prevents double-archiving even if called /// concurrently from multiple relay pods. pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let rows = sqlx::query( "UPDATE channels AS ch SET archived_at = NOW() \ FROM communities AS c \ @@ -726,7 +796,7 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result Result { + get_channel_with_operation( + &self.pool, + community_id, + channel_id, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Returns the canvas content for a channel, if any. #[datastore_span(name = "get_canvas", system = "postgresql")] pub async fn get_canvas( @@ -841,6 +928,22 @@ impl Db { list_channels(&self.pool, community_id, visibility).await } + /// Lists channels during startup reconciliation. + #[datastore_span(name = "list_channels_for_bootstrap", system = "postgresql")] + pub async fn list_channels_for_bootstrap( + &self, + community_id: CommunityId, + visibility: Option<&str>, + ) -> Result> { + list_channels_with_operation( + &self.pool, + community_id, + visibility, + crate::observability::WriterOperation::Bootstrap, + ) + .await + } + /// Updates a channel's name and/or description. #[datastore_span(name = "update_channel", system = "postgresql")] pub async fn update_channel( @@ -910,15 +1013,13 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::user::ensure_user; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index f0fd3332acd..8280ca01f82 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -100,7 +100,12 @@ pub async fn verify_channel_roster_fence_catalog<'e>( /// function. This rolled-back probe verifies that a canonical empty roster is /// accepted while a stale roster member is rejected with `check_violation`. pub async fn verify_channel_roster_fence_behavior(pool: &sqlx::PgPool) -> Result<()> { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let community_id = Uuid::new_v4(); let channel_id = Uuid::new_v4(); sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") @@ -323,7 +328,12 @@ pub async fn lock_member_snapshot( channel_id: Uuid, relay_pubkey: &[u8], ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // Match the canonical replacement writer's lock order. Old binaries take // this key before INSERT; migration 0032 then takes the membership key in // the INSERT trigger. Taking both in that order avoids mixed-version @@ -396,7 +406,12 @@ pub async fn add_member( ))); } - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the whole role-check / owner-count / upsert // sequence against concurrent membership writes on this channel. @@ -577,7 +592,12 @@ pub async fn remove_member( crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? }; - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the actor-role check, the last-owner count and // the UPDATE against concurrent membership writes on this channel (same key @@ -648,6 +668,11 @@ pub async fn is_member( channel_id: Uuid, pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members cm \ JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ @@ -656,7 +681,7 @@ pub async fn is_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -674,6 +699,11 @@ pub async fn membership_pairs( if channel_ids.is_empty() || pubkeys.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( "SELECT cm.channel_id, cm.pubkey FROM channel_members cm \ JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ @@ -682,7 +712,7 @@ pub async fn membership_pairs( .bind(community_id.as_uuid()) .bind(channel_ids) .bind(pubkeys) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() .map(|row| Ok((row.try_get("channel_id")?, row.try_get("pubkey")?))) @@ -702,6 +732,22 @@ pub async fn get_members( community_id: CommunityId, channel_id: Uuid, ) -> Result> { + get_members_with_operation( + pool, + community_id, + channel_id, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn get_members_with_operation( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let rows = sqlx::query( r#" SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at @@ -713,7 +759,7 @@ pub async fn get_members( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter().map(row_to_member_record).collect() } @@ -733,6 +779,11 @@ pub async fn get_members_bulk( if channel_ids.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at @@ -744,7 +795,7 @@ pub async fn get_members_bulk( ) .bind(community_id.as_uuid()) .bind(channel_ids) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter().map(row_to_member_record).collect() } @@ -758,6 +809,11 @@ pub async fn get_accessible_channel_ids( community_id: CommunityId, pubkey: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT cm.channel_id @@ -772,7 +828,7 @@ pub async fn get_accessible_channel_ids( ) .bind(community_id.as_uuid()) .bind(pubkey) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -806,6 +862,11 @@ pub async fn list_large_channel_rosters_needing_reconciliation( minimum_members: i64, relay_pubkey: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let rows = sqlx::query( r#" WITH large_rosters AS ( @@ -843,7 +904,7 @@ pub async fn list_large_channel_rosters_needing_reconciliation( ) .bind(minimum_members) .bind(relay_pubkey) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -964,6 +1025,11 @@ pub async fn get_accessible_channels( visibility_filter: Option<&str>, member_only: Option, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; // When `member_only` is `Some(true)`, restrict to channels where the user // has an active membership (cm.channel_id IS NOT NULL). This is a strict // subset of the default result set and is pushed into SQL so the LIMIT 1000 @@ -1008,7 +1074,7 @@ pub async fn get_accessible_channels( query }; - let rows = query.fetch_all(pool).await?; + let rows = query.fetch_all(&mut *connection).await?; rows.into_iter() .map(|row| { let is_member: bool = row.try_get("is_member").unwrap_or(false); @@ -1027,6 +1093,11 @@ pub async fn get_bot_members( pool: &PgPool, community_id: CommunityId, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT cm.pubkey, u.display_name, u.agent_type, u.capabilities, @@ -1040,7 +1111,7 @@ pub async fn get_bot_members( "#, ) .bind(community_id.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let mut out = Vec::with_capacity(rows.len()); @@ -1071,10 +1142,26 @@ pub async fn get_users_bulk( pool: &PgPool, community_id: CommunityId, pubkeys: &[Vec], +) -> Result> { + get_users_bulk_with_operation( + pool, + community_id, + pubkeys, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await +} + +async fn get_users_bulk_with_operation( + pool: &PgPool, + community_id: CommunityId, + pubkeys: &[Vec], + operation: crate::observability::WriterOperation, ) -> Result> { if pubkeys.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer(pool, operation).await?; // Build a parameterised IN clause: ($2, $3, ...); $1 is community_id. let placeholders = (2..(pubkeys.len() + 2)) @@ -1091,7 +1178,7 @@ pub async fn get_users_bulk( q = q.bind(pk); } - let rows = q.fetch_all(pool).await?; + let rows = q.fetch_all(&mut *connection).await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { @@ -1124,12 +1211,17 @@ pub async fn get_member_count( community_id: CommunityId, channel_id: Uuid, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND removed_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; Ok(row.try_get("cnt")?) } @@ -1146,6 +1238,11 @@ pub async fn get_member_counts_bulk( if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( "SELECT channel_id, COUNT(*) as cnt FROM channel_members \ @@ -1159,7 +1256,7 @@ pub async fn get_member_counts_bulk( } qb.push(") GROUP BY channel_id"); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *connection).await?; let mut map = std::collections::HashMap::with_capacity(rows.len()); for row in rows { @@ -1179,6 +1276,11 @@ pub async fn get_member_role( channel_id: Uuid, pubkey: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT cm.role::text AS role FROM channel_members cm \ JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ @@ -1187,7 +1289,7 @@ pub async fn get_member_role( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.map(|r| r.try_get("role")).transpose()?) } @@ -1196,7 +1298,14 @@ impl Db { /// Verify the mixed-version channel-roster database fence end to end. #[datastore_span(name = "verify_channel_roster_fence", system = "postgresql")] pub async fn verify_channel_roster_fence(&self) -> Result<()> { - verify_channel_roster_fence_catalog(&self.pool).await?; + { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + verify_channel_roster_fence_catalog(&mut *connection).await?; + } verify_channel_roster_fence_behavior(&self.pool).await } @@ -1277,6 +1386,22 @@ impl Db { get_members(&self.pool, community_id, channel_id).await } + /// Return a channel roster used to build or validate an event mutation. + #[datastore_span(name = "get_members_for_event_write", system = "postgresql")] + pub async fn get_members_for_event_write( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + get_members_with_operation( + &self.pool, + community_id, + channel_id, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Returns active members for multiple channels in a single query. #[datastore_span(name = "get_members_bulk", system = "postgresql")] pub async fn get_members_bulk( @@ -1346,6 +1471,22 @@ impl Db { get_users_bulk(&self.pool, community_id, pubkeys).await } + /// Bulk-fetch user names while constructing an event and its mention tags. + #[datastore_span(name = "get_users_bulk_for_event_write", system = "postgresql")] + pub async fn get_users_bulk_for_event_write( + &self, + community_id: CommunityId, + pubkeys: &[Vec], + ) -> Result> { + get_users_bulk_with_operation( + &self.pool, + community_id, + pubkeys, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Returns the count of active members in a channel. #[datastore_span(name = "get_member_count", system = "postgresql")] pub async fn get_member_count( @@ -1379,7 +1520,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::channel::{ChannelType, ChannelVisibility}; use crate::migration; @@ -1387,10 +1528,8 @@ mod tests { use nostr::Keys; use sqlx::postgres::PgPoolOptions; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } @@ -1573,8 +1712,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn accessible_channel_ids_are_not_truncated_at_one_thousand() { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -1612,8 +1750,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn get_members_returns_full_roster_beyond_1000() { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -1720,11 +1857,15 @@ mod tests { .await .expect("insert large roster"); + // Migration 0032's roster guard requires canonical four-field p tags + // whose roles exactly match channel_members, including the creator's + // owner row created by create_test_channel. + let creator_hex = hex::encode(&creator); let stale_tags: Vec = std::iter::once(serde_json::json!(["d", channel.id.to_string()])) .chain(std::iter::once(serde_json::json!([ "p", - hex::encode(&creator), + creator_hex, "", "owner" ]))) @@ -1736,7 +1877,7 @@ mod tests { std::iter::once(serde_json::json!(["d", channel.id.to_string()])) .chain(std::iter::once(serde_json::json!([ "p", - hex::encode(&creator), + creator_hex, "", "owner" ]))) @@ -1747,8 +1888,14 @@ mod tests { .collect(); let other_complete_tags: Vec = std::iter::once(serde_json::json!(["d", channel.id.to_string()])) + .chain(std::iter::once(serde_json::json!([ + "p", + hex::encode(&creator), + "", + "owner" + ]))) .chain( - (0..=1_500) + (1..=extra_members) .map(|n| serde_json::json!(["p", format!("{n:064x}"), "", "member"])), ) .collect(); @@ -1793,6 +1940,10 @@ mod tests { // The same channel UUID in another tenant is deliberately valid. A // complete snapshot there must not mask this tenant's stale head. let other_community_id = make_test_community(&pool).await; + // Insert directly because create_test_channel generates a fresh UUID, + // while this test needs the same channel ID in both tenants. Direct + // insertion skips the helper's creator membership, so add the owner + // row explicitly below. sqlx::query( r#" INSERT INTO channels @@ -1806,16 +1957,29 @@ mod tests { .execute(&pool) .await .expect("insert same channel id in other tenant"); + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) + VALUES ($1, $2, $3, 'owner', NOW()) + "#, + ) + .bind(other_community_id) + .bind(channel.id) + .bind(&creator) + .execute(&pool) + .await + .expect("insert other-tenant owner"); sqlx::query( r#" INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member', NOW() + (n || ' seconds')::interval - FROM generate_series(0, 1500) n + FROM generate_series(1, $3) n "#, ) .bind(other_community_id) .bind(channel.id) + .bind(extra_members) .execute(&pool) .await .expect("insert complete other-tenant roster"); @@ -2398,7 +2562,7 @@ mod tests { let snapshot_pool = PgPoolOptions::new() .max_connections(1) .acquire_timeout(std::time::Duration::from_secs(1)) - .connect(TEST_DB_URL) + .connect(&crate::test_support::database_url()) .await .expect("connect one-connection pool"); let relay_keys = Keys::generate(); @@ -2470,7 +2634,7 @@ mod tests { /// until it is released. Verified by mutation — dropping the lock from either /// function makes that call return immediately and fails this test. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn membership_writes_serialize_on_the_shared_channel_lock() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2541,7 +2705,7 @@ mod tests { /// holder then demotes the remover and commits. Once the key is released the /// remover must re-read its (now unprivileged) role and be rejected. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn remove_member_rejects_an_actor_demoted_while_it_waited() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2627,7 +2791,7 @@ mod tests { /// Two owners on purpose, so the last-owner guard can never be what /// decides the outcome — only role resolution can. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn kicked_owner_rejoins_as_member_not_owner() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2679,7 +2843,7 @@ mod tests { /// The other side of the same boundary: reactivation may reach an elevated /// role, but only because a *currently* elevated granter asked for it. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn removed_owner_is_restored_only_by_a_current_owner() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2736,7 +2900,7 @@ mod tests { } async fn admin_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) + crate::test_support::database_url() } /// Create a fresh scratch database on the same server and optionally run migrations. @@ -2921,6 +3085,37 @@ mod tests { drop_scratch_db(&admin, pool, &scratch_name).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_roster_fence_verification_supports_size_one_pool() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, scratch_name) = create_scratch_db(&admin, "roster_fence_size_one").await; + seed_pool.close().await; + + let base_url = admin_url().await; + let path = base_url.rfind('/').expect("database URL path"); + let scratch_url = format!("{}/{}", &base_url[..path], scratch_name); + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(&scratch_url) + .await + .expect("connect size-one writer pool"); + let db = Db::from_pool(pool.clone()); + + tokio::time::timeout( + std::time::Duration::from_secs(2), + db.verify_channel_roster_fence(), + ) + .await + .expect("roster verification must not self-deadlock on its second checkout") + .expect("migrated roster fence verifies on a size-one pool"); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn desired_schema_rejects_stale_legacy_roster_role() { diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 5e8462345bb..bcd38f4e5ce 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -91,6 +91,11 @@ impl Db { &self, normalized_host: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::TenantResolution, + ) + .await?; let row = sqlx::query( r#" SELECT id, host @@ -102,7 +107,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { @@ -120,11 +125,37 @@ impl Db { /// Returns whether a community id still exists in the active lifecycle state. #[datastore_span(name = "is_community_active", system = "postgresql")] pub async fn is_community_active(&self, community_id: CommunityId) -> Result { + self.is_community_active_with_operation( + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await + } + + /// Background lifecycle revalidation variant of [`Self::is_community_active`]. + #[datastore_span(name = "is_community_active_for_maintenance", system = "postgresql")] + pub async fn is_community_active_for_maintenance( + &self, + community_id: CommunityId, + ) -> Result { + self.is_community_active_with_operation( + community_id, + crate::observability::WriterOperation::Maintenance, + ) + .await + } + + async fn is_community_active_with_operation( + &self, + community_id: CommunityId, + operation: crate::observability::WriterOperation, + ) -> Result { + let mut connection = crate::observability::acquire_writer(&self.pool, operation).await?; let active = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active')", ) .bind(community_id.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; Ok(active) } @@ -138,9 +169,14 @@ impl Db { &self, normalized_host: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { Ok(CommunityRecord { @@ -161,6 +197,11 @@ impl Db { owner_pubkey: &str, ) -> Result> { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT c.id, c.host, c.created_at, c.archived_at @@ -172,7 +213,7 @@ impl Db { "#, ) .bind(owner_pubkey) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -203,6 +244,11 @@ impl Db { /// is never used to re-derive the community. #[datastore_span(name = "lookup_community_host", system = "postgresql")] pub async fn lookup_community_host(&self, community_id: CommunityId) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::TenantResolution, + ) + .await?; let row = sqlx::query( r#" SELECT host @@ -214,7 +260,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { @@ -255,6 +301,11 @@ impl Db { community_id: CommunityId, icon: Option<&str>, ) -> Result<()> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" UPDATE communities @@ -264,7 +315,7 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(icon) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(()) } @@ -279,6 +330,35 @@ impl Db { &self, normalized_host: &str, ) -> Result { + self.ensure_configured_community_with_operation( + normalized_host, + crate::observability::WriterOperation::Authorization, + ) + .await + } + + /// Ensure the deployment-configured community during process bootstrap. + #[datastore_span( + name = "ensure_configured_community_for_bootstrap", + system = "postgresql" + )] + pub async fn ensure_configured_community_for_bootstrap( + &self, + normalized_host: &str, + ) -> Result { + self.ensure_configured_community_with_operation( + normalized_host, + crate::observability::WriterOperation::Bootstrap, + ) + .await + } + + async fn ensure_configured_community_with_operation( + &self, + normalized_host: &str, + operation: crate::observability::WriterOperation, + ) -> Result { + let mut connection = crate::observability::acquire_writer(&self.pool, operation).await?; let row = sqlx::query( r#" INSERT INTO communities (host) @@ -290,7 +370,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await? .ok_or_else(|| { DbError::AccessDenied(format!( @@ -321,7 +401,12 @@ impl Db { owner_pubkey: &str, ) -> Result { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // Serialize on the owner pubkey so concurrent creates to the same // owner cannot both pass the ownership count check. @@ -412,6 +497,11 @@ impl Db { owner_pubkey: &str, protected_deployment_host: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#"UPDATE communities c SET archived_at = COALESCE(c.archived_at, now()) @@ -428,7 +518,7 @@ impl Db { .bind(normalized_host) .bind(owner_pubkey) .bind(protected_deployment_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { Ok(ArchivedCommunityRecord { @@ -447,6 +537,11 @@ impl Db { normalized_host: &str, owner_pubkey: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#"UPDATE communities c SET archived_at = NULL @@ -461,7 +556,7 @@ impl Db { ) .bind(normalized_host) .bind(owner_pubkey) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { Ok(UnarchivedCommunityRecord { @@ -478,6 +573,11 @@ impl Db { /// they are acting on, rather than falling back to an implicit default. #[datastore_span(name = "community_of_channel", system = "postgresql")] pub async fn community_of_channel(&self, channel_id: Uuid) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::TenantResolution, + ) + .await?; let row = sqlx::query( r#" SELECT community_id @@ -487,7 +587,7 @@ impl Db { "#, ) .bind(channel_id) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { @@ -523,6 +623,11 @@ impl Db { if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let rows = sqlx::query( r#" SELECT id, community_id @@ -532,7 +637,7 @@ impl Db { "#, ) .bind(channel_ids) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await?; let mut out = std::collections::HashMap::with_capacity(rows.len()); @@ -546,7 +651,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { //! Pin the load-bearing contract for `Db::communities_of_channels`: //! a channel id that does NOT exist MUST be absent from the result //! map, never mapped to a default. The relay-side read-row emitter @@ -557,11 +662,8 @@ mod tests { use super::*; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_db() -> Db { - let database_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -847,8 +949,8 @@ mod tests { let db = setup_db().await; let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - // Create 3 communities for this owner (the max). - for i in 0..3 { + // Fill the configured default ownership limit. + for i in 0..crate::relay_members::MAX_COMMUNITIES_PER_OWNER { let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); assert!(matches!( db.create_community_with_owner(&host, &owner) @@ -858,7 +960,7 @@ mod tests { )); } - let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); + let host = format!("limit-test-overflow-{}.example", Uuid::new_v4().simple()); assert_eq!( db.create_community_with_owner(&host, &owner) .await diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index c7fcdc09f66..0e184e00d88 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -631,7 +631,38 @@ pub struct DeletionStore { impl Db { /// Validate the minimum deletion fence catalog required by serving paths. pub async fn validate_deletion_serving_catalog(&self) -> Result<()> { - self.deletion_store().validate_serving_catalog().await + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + self.deletion_store() + .validate_serving_catalog_on(&mut connection) + .await + } + + /// Validate the serving catalog inside the readiness request's absolute + /// deadline, attributing only the one real writer checkout to readiness. + pub async fn validate_deletion_serving_catalog_for_readiness( + &self, + deadline: tokio::time::Instant, + ) -> Result<()> { + let mut connection = crate::observability::acquire_writer_until( + &self.pool, + crate::observability::WriterOperation::Readiness, + deadline, + ) + .await?; + match tokio::time::timeout_at( + deadline, + self.deletion_store() + .validate_serving_catalog_on(&mut connection), + ) + .await + { + Err(_) => Err(sqlx::Error::PoolTimedOut.into()), + Ok(result) => result, + } } /// Validate the exact live community-deletion tenant catalog for destruction. @@ -784,13 +815,22 @@ impl DeletionStore { /// Validate the deletion catalog contract required by relay serving. pub async fn validate_serving_catalog(&self) -> Result<()> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + self.validate_serving_catalog_on(&mut connection).await + } + + async fn validate_serving_catalog_on(&self, conn: &mut PgConnection) -> Result<()> { let runtime_columns = sqlx::query( "SELECT attname, format_type(atttypid, atttypmod) AS type_name, attnotnull \ FROM pg_attribute WHERE attrelid = 'communities'::regclass \ AND attname IN ('deletion_state', 'deletion_fence_generation', 'deleted_at') \ AND NOT attisdropped ORDER BY attname", ) - .fetch_all(&self.pool) + .fetch_all(&mut *conn) .await?; let column_contract = runtime_columns .iter() @@ -836,7 +876,7 @@ impl DeletionStore { ORDER BY table_name", ) .bind(&required_table_names) - .fetch_all(&self.pool) + .fetch_all(&mut *conn) .await? .into_iter() .collect(); @@ -856,7 +896,7 @@ impl DeletionStore { .copied() .map(str::to_owned) .collect::>(); - let live_fences = self.live_fenced_tables().await?; + let live_fences = live_fenced_tables_on(&mut *conn).await?; let missing_fences = required_fences .difference(&live_fences) .cloned() @@ -882,7 +922,7 @@ impl DeletionStore { AND p.proname = 'enforce_community_tombstone' \ AND NOT t.tgisinternal AND t.tgenabled = 'O')", ) - .fetch_one(&self.pool) + .fetch_one(&mut *conn) .await?; if !required_objects_present { return Err(DbError::DeletionSafety( @@ -2361,7 +2401,12 @@ impl DeletionStore { lease_duration: Duration, ) -> Result { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // The assertion owns both the shared ordering lock and the supported // READ COMMITTED check. The lease table is trigger-excluded, so this // explicit admission is its database-enforced write fence. @@ -2425,7 +2470,12 @@ impl DeletionStore { lease_duration: Duration, ) -> Result<()> { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; let lease_until: Option> = sqlx::query_scalar( "UPDATE community_serving_write_leases lease \ @@ -2458,6 +2508,11 @@ impl DeletionStore { /// Release a serving side-effect lease. A stale release is harmless. pub async fn release_serving_write_lease(&self, lease: &ServingWriteLease) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let deleted = sqlx::query( "DELETE FROM community_serving_write_leases \ WHERE id = $1 AND community_id = $2 AND owner = $3 AND generation = $4 \ @@ -2468,7 +2523,7 @@ impl DeletionStore { .bind(&lease.owner) .bind(lease.generation) .bind(lease.fence_generation) - .execute(&self.pool) + .execute(&mut *connection) .await? .rows_affected(); Ok(deleted == 1) @@ -2480,7 +2535,12 @@ impl DeletionStore { /// work remains blocked, preserving an accurate drain without abandoning an /// admitted remote effect. pub async fn verify_serving_write_lease(&self, lease: &ServingWriteLease) -> Result<()> { - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ @@ -2512,6 +2572,11 @@ impl DeletionStore { /// Delete expired serving leases in a bounded batch. pub async fn reap_expired_serving_write_leases(&self, limit: i64) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let affected = sqlx::query( "WITH expired AS ( \ SELECT id FROM community_serving_write_leases \ @@ -2521,7 +2586,7 @@ impl DeletionStore { USING expired WHERE lease.id = expired.id", ) .bind(limit.clamp(1, 10_000)) - .execute(&self.pool) + .execute(&mut *connection) .await? .rows_affected(); Ok(affected) @@ -2529,6 +2594,11 @@ impl DeletionStore { /// Return serving-lease counts and dead-tuple estimate for observability. pub async fn serving_lease_stats(&self) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let row = sqlx::query( "SELECT count(*) FILTER (WHERE lease_until >= now())::BIGINT AS active, \ count(*) FILTER (WHERE lease_until < now())::BIGINT AS expired, \ @@ -2536,7 +2606,7 @@ impl DeletionStore { WHERE relname = 'community_serving_write_leases'), 0) AS dead_tuples \ FROM community_serving_write_leases", ) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; Ok(ServingLeaseStats { active: row.try_get("active")?, @@ -2547,12 +2617,17 @@ impl DeletionStore { /// Whether a community remains active and serving-write eligible. pub async fn is_serving_active(&self, community: CommunityId) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 \ AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active')", ) .bind(community.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await .map_err(Into::into) } @@ -3355,7 +3430,9 @@ mod postgres_tests { }) .await .expect("connect deletion test DB"); - db.migrate().await.expect("migrate deletion test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate deletion test DB"); + } let store = db.deletion_store(); (db, store) } @@ -4014,7 +4091,7 @@ mod postgres_tests { .expect("won claim"); let mut open_write = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("open write transaction"); sqlx::query("INSERT INTO pubkey_allowlist (community_id, pubkey) VALUES ($1, $2)") diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 60e6b05ef9b..cb45809eadb 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -221,12 +221,68 @@ fn huddle_started_content_links(content: &str, ephemeral_channel_id: Uuid) -> bo .is_some_and(|id| id == ephemeral_channel_id) } -/// Return whether `parent_channel_id` has a creator-signed huddle-start event -/// that links to `ephemeral_channel_id`. +/// Resolve creator-authenticated parent links for a bounded set of huddle sessions. /// /// The creator constraint matters: a member of some unrelated channel can post /// their own kind:48100 event there, but they cannot sign as the creator of the -/// target ephemeral channel. +/// target ephemeral channel. One set-based query replaces the liveness +/// endpoint's former session × parent lookup loop. Malformed historical start +/// content is ignored rather than aborting the complete liveness snapshot. +pub async fn huddle_started_links( + pool: &PgPool, + community_id: CommunityId, + parent_channel_ids: &[Uuid], + ephemeral_channel_ids: &[Uuid], +) -> Result)>> { + if parent_channel_ids.is_empty() || ephemeral_channel_ids.is_empty() { + return Ok(Vec::new()); + } + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (backing.id) + backing.id AS session_id, + start.channel_id AS parent_channel_id, + backing.created_by + FROM events start + JOIN channels backing + ON backing.community_id = start.community_id + AND backing.id::text = CASE + WHEN start.content IS JSON OBJECT + THEN (start.content::json ->> 'ephemeral_channel_id') + ELSE NULL + END + AND backing.deleted_at IS NULL + WHERE start.deleted_at IS NULL + AND start.community_id = $1 + AND start.channel_id = ANY($2) + AND start.kind = $3 + AND octet_length(start.content) <= $5 + AND backing.id = ANY($4) + AND start.pubkey = backing.created_by + ORDER BY backing.id, start.created_at DESC, start.id ASC + "#, + ) + .bind(community_id.as_uuid()) + .bind(parent_channel_ids) + .bind(KIND_HUDDLE_STARTED as i32) + .bind(ephemeral_channel_ids) + .bind(HUDDLE_LINK_CONTENT_MAX_BYTES) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| { + Ok(( + row.try_get("session_id")?, + row.try_get("parent_channel_id")?, + row.try_get("created_by")?, + )) + }) + .collect() +} + +/// Return whether a creator-signed huddle-start event links a parent channel +/// to the requested ephemeral huddle channel. pub async fn huddle_started_link_exists( pool: &PgPool, community_id: CommunityId, @@ -234,6 +290,26 @@ pub async fn huddle_started_link_exists( ephemeral_channel_id: Uuid, creator_pubkey: &[u8], ) -> Result { + huddle_started_link_exists_with_operation( + pool, + community_id, + parent_channel_id, + ephemeral_channel_id, + creator_pubkey, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn huddle_started_link_exists_with_operation( + pool: &PgPool, + community_id: CommunityId, + parent_channel_id: Uuid, + ephemeral_channel_id: Uuid, + creator_pubkey: &[u8], + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let uuid_needle = format!("%{}%", ephemeral_channel_id); let candidates: Vec = sqlx::query_scalar( r#" @@ -257,7 +333,7 @@ pub async fn huddle_started_link_exists( .bind(HUDDLE_LINK_CONTENT_MAX_BYTES) .bind(uuid_needle) .bind(HUDDLE_LINK_CANDIDATE_LIMIT) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(candidates @@ -274,7 +350,11 @@ pub async fn insert_event( event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let mut connection = pool.acquire().await?; + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; insert_event_on(&mut connection, community_id, event, channel_id).await } @@ -355,7 +435,20 @@ async fn insert_event_on( /// Uses `QueryBuilder` for dynamic filter composition — avoids string concatenation /// while keeping all user values in bind parameters. pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result> { - let mut conn = pool.acquire().await?; + query_events_with_operation( + pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await +} + +pub(crate) async fn query_events_with_operation( + pool: &PgPool, + q: &EventQuery, + operation: crate::observability::WriterOperation, +) -> Result> { + let mut conn = crate::observability::acquire_writer(pool, operation).await?; query_events_on(&mut conn, q).await } @@ -659,7 +752,11 @@ pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result Result { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; count_events_on(&mut conn, q).await } @@ -823,12 +920,17 @@ pub async fn soft_delete_event( community_id: CommunityId, event_id: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(event_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -873,6 +975,11 @@ pub async fn soft_delete_by_coordinate( ) -> Result { let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; 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 \ @@ -883,7 +990,7 @@ pub async fn soft_delete_by_coordinate( .bind(pubkey) .bind(d_tag) .bind(deletion_created_at) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -901,7 +1008,12 @@ pub async fn soft_delete_event_and_update_thread( parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", @@ -949,6 +1061,11 @@ pub async fn get_last_message_at( community_id: CommunityId, channel_id: uuid::Uuid, ) -> Result>> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let row = sqlx::query( "SELECT created_at FROM events \ WHERE community_id = $1 AND channel_id = $2 AND deleted_at IS NULL \ @@ -956,7 +1073,7 @@ pub async fn get_last_message_at( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -977,6 +1094,11 @@ pub async fn get_last_message_at_bulk( if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let mut qb: QueryBuilder = QueryBuilder::new( "SELECT channel_id, MAX(created_at) as last_at FROM events \ @@ -990,7 +1112,7 @@ pub async fn get_last_message_at_bulk( } qb.push(") GROUP BY channel_id"); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *connection).await?; let mut map = std::collections::HashMap::with_capacity(rows.len()); for row in rows { @@ -1011,13 +1133,29 @@ pub async fn get_event_by_id( community_id: CommunityId, id_bytes: &[u8], ) -> Result> { + get_event_by_id_with_operation( + pool, + community_id, + id_bytes, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +pub(crate) async fn get_event_by_id_with_operation( + pool: &PgPool, + community_id: CommunityId, + id_bytes: &[u8], + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(id_bytes) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -1038,6 +1176,11 @@ pub async fn get_latest_global_replaceable( kind: i32, pubkey_bytes: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events \ @@ -1048,7 +1191,7 @@ pub async fn get_latest_global_replaceable( .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey_bytes) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -1067,13 +1210,29 @@ pub async fn get_event_by_id_including_deleted( community_id: CommunityId, id_bytes: &[u8], ) -> Result> { + get_event_by_id_including_deleted_with_operation( + pool, + community_id, + id_bytes, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +pub(crate) async fn get_event_by_id_including_deleted_with_operation( + pool: &PgPool, + community_id: CommunityId, + id_bytes: &[u8], + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events WHERE community_id = $1 AND id = $2 ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(id_bytes) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -1090,11 +1249,26 @@ pub async fn get_events_by_ids( pool: &PgPool, community_id: CommunityId, ids: &[&[u8]], +) -> Result> { + get_events_by_ids_with_operation( + pool, + community_id, + ids, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await +} + +pub(crate) async fn get_events_by_ids_with_operation( + pool: &PgPool, + community_id: CommunityId, + ids: &[&[u8]], + operation: crate::observability::WriterOperation, ) -> Result> { if ids.is_empty() { return Ok(vec![]); } - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer(pool, operation).await?; get_events_by_ids_on(&mut conn, community_id, ids).await } @@ -1340,7 +1514,12 @@ pub async fn insert_event_with_thread_metadata( channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let result = insert_event_with_thread_metadata_tx(&mut tx, community_id, event, channel_id, thread_meta) .await?; @@ -1378,7 +1557,46 @@ impl Db { /// explicit, per-callsite decision, never a change to this method. #[datastore_span(name = "query_events", system = "postgresql")] pub async fn query_events(&self, q: &EventQuery) -> Result> { - crate::event::query_events(&self.pool, q).await + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::Authorization, + ) + .await + } + + /// Query authoritative event state that directly controls a durable event + /// mutation or its post-commit side effects. + #[datastore_span(name = "query_events_for_event_write", system = "postgresql")] + pub async fn query_events_for_event_write(&self, q: &EventQuery) -> Result> { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + + /// Query authoritative event state for startup reconciliation. + #[datastore_span(name = "query_events_for_bootstrap", system = "postgresql")] + pub async fn query_events_for_bootstrap(&self, q: &EventQuery) -> Result> { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::Bootstrap, + ) + .await + } + + /// Query authoritative event state for background reconciliation or repair. + #[datastore_span(name = "query_events_for_maintenance", system = "postgresql")] + pub async fn query_events_for_maintenance(&self, q: &EventQuery) -> Result> { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::Maintenance, + ) + .await } /// [`Db::query_events`] with replica routing — the opt-in fast path for @@ -1404,7 +1622,14 @@ impl Db { q: &EventQuery, ) -> Result> { let predicate = crate::RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); - match self.route_read(path, predicate).await { + match self + .route_read( + path, + predicate, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::query_events_on(&mut tx, q).await { Ok(events) => { @@ -1416,11 +1641,23 @@ impl Db { // writer rather than surfacing a routed error. tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - crate::event::query_events(&self.pool, q).await + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } - crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + crate::RouteDecision::Writer => { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await + } } } @@ -1438,7 +1675,14 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { - match self.route_read(path, crate::RoutePredicate::Bounded).await { + match self + .route_read( + path, + crate::RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::query_events_on(&mut tx, q).await { Ok(events) => { @@ -1448,11 +1692,23 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - crate::event::query_events(&self.pool, q).await + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } - crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + crate::RouteDecision::Writer => { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await + } } } @@ -1477,7 +1733,14 @@ impl Db { /// the error to the accepted budget `B`. #[datastore_span(name = "count_events_routed", system = "postgresql")] pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { - match self.route_read(path, crate::RoutePredicate::Bounded).await { + match self + .route_read( + path, + crate::RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::count_events_on(&mut tx, q).await { Ok(count) => { @@ -1495,8 +1758,25 @@ impl Db { } } + /// Resolve creator-signed huddle-start links for bounded parent/session sets. + #[datastore_span(name = "huddle_started_links", system = "postgresql")] + pub async fn huddle_started_links( + &self, + community_id: CommunityId, + parent_channel_ids: &[Uuid], + ephemeral_channel_ids: &[Uuid], + ) -> Result)>> { + crate::event::huddle_started_links( + &self.pool, + community_id, + parent_channel_ids, + ephemeral_channel_ids, + ) + .await + } + /// Return whether a creator-signed huddle-start event links a parent - /// channel to an ephemeral huddle channel. + /// channel to the requested ephemeral huddle channel. #[datastore_span(name = "huddle_started_link_exists", system = "postgresql")] pub async fn huddle_started_link_exists( &self, @@ -1515,6 +1795,29 @@ impl Db { .await } + /// Validate a huddle link while admitting a huddle event for persistence. + #[datastore_span( + name = "huddle_started_link_exists_for_event_write", + system = "postgresql" + )] + pub async fn huddle_started_link_exists_for_event_write( + &self, + community_id: CommunityId, + parent_channel_id: Uuid, + ephemeral_channel_id: Uuid, + creator_pubkey: &[u8], + ) -> Result { + crate::event::huddle_started_link_exists_with_operation( + &self.pool, + community_id, + parent_channel_id, + ephemeral_channel_id, + creator_pubkey, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Fetch the latest replaceable event for a (kind, pubkey) pair. /// /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. @@ -1543,6 +1846,23 @@ impl Db { crate::event::get_event_by_id(&self.pool, community_id, id_bytes).await } + /// Fetch an event as a prerequisite of an event write or durable + /// post-write side effect. + #[datastore_span(name = "get_event_by_id_for_event_write", system = "postgresql")] + pub async fn get_event_by_id_for_event_write( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id_with_operation( + &self.pool, + community_id, + id_bytes, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. #[datastore_span(name = "get_event_by_id_including_deleted", system = "postgresql")] pub async fn get_event_by_id_including_deleted( @@ -1553,6 +1873,26 @@ impl Db { crate::event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await } + /// Fetch an event including tombstones as a prerequisite of an event + /// write or durable post-write side effect. + #[datastore_span( + name = "get_event_by_id_including_deleted_for_event_write", + system = "postgresql" + )] + pub async fn get_event_by_id_including_deleted_for_event_write( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id_including_deleted_with_operation( + &self.pool, + community_id, + id_bytes, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. #[datastore_span(name = "soft_delete_event", system = "postgresql")] pub async fn soft_delete_event( @@ -1633,7 +1973,13 @@ impl Db { community_id: CommunityId, ids: &[&[u8]], ) -> Result> { - crate::event::get_events_by_ids(&self.pool, community_id, ids).await + crate::event::get_events_by_ids_with_operation( + &self.pool, + community_id, + ids, + crate::observability::WriterOperation::Authorization, + ) + .await } /// [`Db::get_events_by_ids`] with replica routing — same contract and @@ -1650,7 +1996,14 @@ impl Db { community_id: CommunityId, ids: &[&[u8]], ) -> Result> { - match self.route_read(path, crate::RoutePredicate::Bounded).await { + match self + .route_read( + path, + crate::RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::get_events_by_ids_on(&mut tx, community_id, ids).await { Ok(events) => { @@ -1660,12 +2013,24 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - crate::event::get_events_by_ids(&self.pool, community_id, ids).await + crate::event::get_events_by_ids_with_operation( + &self.pool, + community_id, + ids, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } crate::RouteDecision::Writer => { - crate::event::get_events_by_ids(&self.pool, community_id, ids).await + crate::event::get_events_by_ids_with_operation( + &self.pool, + community_id, + ids, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } @@ -1703,6 +2068,11 @@ impl Db { /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. #[datastore_span(name = "backfill_d_tags", system = "postgresql")] pub async fn backfill_d_tags(&self) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; let result = sqlx::query( "UPDATE events \ SET d_tag = COALESCE( \ @@ -1713,7 +2083,7 @@ impl Db { WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL \ AND community_write_allowed(community_id)", ) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } @@ -1726,6 +2096,11 @@ impl Db { channel_id: Uuid, relay_pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", @@ -1733,14 +2108,14 @@ impl Db { .bind(community_id.as_uuid()) .bind(channel_id) .bind(relay_pubkey) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -2421,6 +2796,47 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn huddle_started_links_batches_valid_creator_links_and_ignores_malformed_content() { + let pool = setup_pool().await; + let community_uuid = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_uuid); + let parent = make_test_channel(&pool, community_uuid, None).await; + let session = make_test_channel(&pool, community_uuid, Some(60)).await; + let creator = vec![7_u8; 32]; + + for (index, content) in [ + "not-json".to_owned(), + serde_json::json!({ "ephemeral_channel_id": session }).to_string(), + ] + .into_iter() + .enumerate() + { + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \ + VALUES ($1, $2, $3, NOW() + make_interval(secs => $4), $5, '[]', $6, $7, $8)", + ) + .bind(community_uuid) + .bind(vec![(index + 1) as u8; 32]) + .bind(&creator) + .bind(index as f64) + .bind(KIND_HUDDLE_STARTED as i32) + .bind(content) + .bind(vec![0_u8; 64]) + .bind(parent) + .execute(&pool) + .await + .expect("insert huddle-start candidate"); + } + + let links = huddle_started_links(&pool, community, &[parent], &[session]) + .await + .expect("batch huddle links"); + assert_eq!(links, vec![(session, parent, creator)]); + } + #[test] fn huddle_started_content_requires_matching_ephemeral_field() { let channel_id = Uuid::new_v4(); diff --git a/crates/buzz-db/src/store/feed.rs b/crates/buzz-db/src/store/feed.rs index 01e4fef32be..5819136fe6a 100644 --- a/crates/buzz-db/src/store/feed.rs +++ b/crates/buzz-db/src/store/feed.rs @@ -134,7 +134,11 @@ pub async fn query_mentions( since: Option>, limit: i64, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; query_mentions_on( &mut conn, community, @@ -218,7 +222,11 @@ pub async fn query_needs_action( since: Option>, limit: i64, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; query_needs_action_on( &mut conn, community, @@ -287,7 +295,11 @@ pub async fn query_activity( since: Option>, limit: i64, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; query_activity_on(&mut conn, community, accessible_channel_ids, since, limit).await } @@ -345,7 +357,14 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_mentions_on( &mut tx, community, @@ -422,7 +441,14 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => { match crate::feed::query_needs_action_on( &mut tx, @@ -492,7 +518,14 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_activity_on( &mut tx, community, @@ -536,7 +569,7 @@ impl Db { // -- Tests -------------------------------------------------------------------- #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; @@ -1120,14 +1153,11 @@ mod tests { /// `insert_mentions` must index every p-tag even past Postgres's /// bind-parameter statement cap. /// - /// Relay-signed kind 39002 member snapshots carry one p-tag per channel - /// member, and a multi-row INSERT binds 6 parameters per row — a single - /// statement tops out at ~10.9k rows against the 65,535-parameter limit. - /// Clients discover their channels via `{kinds:[39002], "#p":[me]}`, so a - /// failed insert silently breaks discovery for the whole channel. + /// A multi-row INSERT binds 6 parameters per p-tag, so a single statement + /// tops out at ~10.9k rows against the 65,535-parameter limit. #[tokio::test] #[ignore = "requires Postgres"] - async fn insert_mentions_indexes_rosters_past_bind_parameter_cap() { + async fn insert_mentions_indexes_p_tags_past_bind_parameter_cap() { let pool = setup_pool().await; let community = CommunityId::from_uuid(make_test_community(&pool).await); let channel = insert_test_channel(&pool, community).await; @@ -1148,7 +1178,15 @@ mod tests { let tags: Vec = (1..=mention_count) .map(|n| Tag::parse(["p", &format!("{n:064x}"), "", "member"]).expect("p tag")) .collect(); - let event = store_feed_event(&pool, community, 39002, "", Some(channel), tags).await; + let event = store_feed_event( + &pool, + community, + KIND_STREAM_MESSAGE, + "", + Some(channel), + tags, + ) + .await; let indexed: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2", @@ -1160,7 +1198,7 @@ mod tests { .expect("count indexed mentions"); assert_eq!( indexed as usize, mention_count, - "every roster p-tag must land in event_mentions" + "every p-tag must land in event_mentions" ); } } diff --git a/crates/buzz-db/src/store/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs index 5afea1e4fda..fac10c3f610 100644 --- a/crates/buzz-db/src/store/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -50,13 +50,18 @@ pub async fn repo_name_owner( community: CommunityId, repo_id: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT owner_pubkey FROM git_repo_names \ WHERE community_id = $1 AND repo_id = $2", ) .bind(community.as_uuid()) .bind(repo_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|r| r.try_get("owner_pubkey")) .transpose() @@ -85,6 +90,11 @@ pub async fn reserve_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Atomic claim: insert only if the (community, repo) is free. RETURNING is // non-empty exactly when *this* statement inserted the row, so it cleanly // distinguishes "I claimed it" from "someone already holds it" without a @@ -98,7 +108,7 @@ pub async fn reserve_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; if inserted.is_some() { @@ -113,7 +123,7 @@ pub async fn reserve_repo_name( ) .bind(community.as_uuid()) .bind(repo_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match existing { @@ -145,13 +155,18 @@ pub async fn count_repos_for_owner( community: CommunityId, owner_pubkey: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) AS n FROM git_repo_names \ WHERE community_id = $1 AND owner_pubkey = $2", ) .bind(community.as_uuid()) .bind(owner_pubkey) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; row.try_get("n").map_err(crate::error::DbError::from) } @@ -168,6 +183,11 @@ pub async fn release_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "DELETE FROM git_repo_names \ WHERE community_id = $1 AND repo_id = $2 AND owner_pubkey = $3", @@ -175,7 +195,7 @@ pub async fn release_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } @@ -231,7 +251,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index 5ac7c93af9a..b5cc4d30930 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -464,6 +464,11 @@ pub async fn restriction_state( community: CommunityId, pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#" SELECT @@ -475,7 +480,7 @@ pub async fn restriction_state( ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -814,7 +819,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use chrono::Duration; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/partition.rs b/crates/buzz-db/src/store/partition.rs index ba252f71f4a..179ba60b782 100644 --- a/crates/buzz-db/src/store/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -16,6 +16,11 @@ const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"]; /// Ensures monthly partition tables exist for the next `months_ahead` months. pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Result<()> { let now = Utc::now(); + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; for i in 0..=(months_ahead as i32) { let year = now.year(); @@ -50,7 +55,7 @@ pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Resul let end_str = end.format("%Y-%m-%d").to_string(); for table in PARTITIONED_TABLES { - ensure_partition(pool, table, &start_str, &end_str, &suffix).await?; + ensure_partition(&mut connection, table, &start_str, &end_str, &suffix).await?; } } @@ -82,7 +87,7 @@ fn validate_date_str(s: &str) -> bool { } async fn ensure_partition( - pool: &PgPool, + connection: &mut sqlx::PgConnection, table_name: &str, start_date_str: &str, end_date_str: &str, @@ -123,7 +128,7 @@ async fn ensure_partition( "#, ) .bind(&partition_name) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; @@ -137,7 +142,10 @@ async fn ensure_partition( FOR VALUES FROM ('{start_date_str}') TO ('{end_date_str}')" ); - match sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await { + match sqlx::query(sqlx::AssertSqlSafe(sql)) + .execute(&mut *connection) + .await + { Ok(_) => { info!("added partition {partition_name}"); Ok(()) diff --git a/crates/buzz-db/src/store/product_feedback.rs b/crates/buzz-db/src/store/product_feedback.rs index 8a0ef36bea5..e732c44d4b9 100644 --- a/crates/buzz-db/src/store/product_feedback.rs +++ b/crates/buzz-db/src/store/product_feedback.rs @@ -137,7 +137,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[tokio::test] diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 9133b82e716..59c44fc5a83 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -14,6 +14,21 @@ use crate::error::Result; use crate::Db; use buzz_datastore_tracing::datastore_span; +async fn acquire_operation_connection( + pool: &PgPool, + operation: crate::observability::WriterOperation, +) -> Result> { + Ok(crate::observability::acquire_writer(pool, operation).await?) +} + +async fn begin_operation_transaction( + pool: &PgPool, + operation: crate::observability::WriterOperation, +) -> Result> { + let connection = acquire_operation_connection(pool, operation).await?; + Ok(sqlx::Transaction::begin(connection, None).await?) +} + /// Namespace for the per-community push-gate advisory lock. Must match the /// key built inside the `enqueue_push_match_job` trigger (migration 0023): /// event inserts take it SHARED there; every lease transition that can make @@ -498,7 +513,9 @@ async fn replace_lease( // lease" to "eligible"; serialize it against the trigger's shared gate // lock (gate → lease row, matching accept_lease_event's global order). // Revocations (is_active = false) never make eligibility true and skip it. - let mut tx = pool.begin().await?; + let mut tx = + begin_operation_transaction(pool, crate::observability::WriterOperation::EventWrite) + .await?; if is_active { acquire_push_gate_lock(&mut tx, community).await?; } @@ -651,7 +668,9 @@ pub async fn enqueue_wakes( if requests.is_empty() { return Ok(Vec::new()); } - let mut tx = pool.begin().await?; + let mut tx = + begin_operation_transaction(pool, crate::observability::WriterOperation::Maintenance) + .await?; // 1. Lock and read the current lease row for every distinct requested // (author, installation), in deterministic order. @@ -854,7 +873,13 @@ pub async fn claim_due_match_batch( lease_until, |pool, community, ids| async move { let refs: Vec<&[u8]> = ids.iter().map(Vec::as_slice).collect(); - crate::event::get_events_by_ids(&pool, community, &refs).await + crate::event::get_events_by_ids_with_operation( + &pool, + community, + &refs, + crate::observability::WriterOperation::Maintenance, + ) + .await }, ) .await @@ -871,6 +896,9 @@ where Fut: std::future::Future>>, { let claim_id = Uuid::new_v4(); + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let rows = sqlx::query( r#" WITH target AS ( @@ -905,11 +933,16 @@ where .bind(lease_until) .bind(MAX_MATCH_ATTEMPTS) .bind(limit) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; if rows.is_empty() { return Ok(None); } + // The claim query is a single autocommitted statement. Release its pool + // slot before loading source events, because the production loader owns a + // separately attributed acquisition. Holding this connection across the + // load would self-starve a supported size-one writer pool. + drop(connection); let community = CommunityId::from_uuid(rows[0].try_get("community_id")?); let mut attempts = std::collections::HashMap::with_capacity(rows.len()); for row in &rows { @@ -932,6 +965,9 @@ where // recoverable after their claim lease expires. let gone: Vec> = attempts.into_keys().collect(); if !gone.is_empty() { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -939,7 +975,7 @@ where .bind(community.as_uuid()) .bind(claim_id) .bind(&gone) - .execute(pool) + .execute(&mut *connection) .await?; } if jobs.is_empty() { @@ -959,26 +995,32 @@ where /// served by the due partial index, so putting it in every claim made claims /// slower exactly when a backlog needed them fastest. pub async fn reap_exhausted_matches(pool: &PgPool) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; Ok(sqlx::query( "DELETE FROM push_match_queue WHERE attempts >= $1 \ AND (state='pending' OR (state='matching' AND lease_until < now())) \ AND community_write_allowed(community_id)", ) .bind(MAX_MATCH_ATTEMPTS) - .execute(pool) + .execute(&mut *connection) .await? .rows_affected()) } /// Load active endpoint-enabled leases for one tenant. pub async fn active_match_leases(pool: &PgPool, community: CommunityId) -> Result> { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let rows = sqlx::query( "SELECT author, installation_id, generation, subscriptions, expires_at \ FROM push_leases WHERE community_id=$1 AND active AND endpoint_enabled \ AND expires_at > EXTRACT(EPOCH FROM now())::bigint", ) .bind(community.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() .map(|row| { @@ -1005,6 +1047,9 @@ pub async fn complete_match_batch( if event_ids.is_empty() { return Ok(0); } + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; Ok(sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -1012,7 +1057,7 @@ pub async fn complete_match_batch( .bind(community.as_uuid()) .bind(claim_id) .bind(event_ids) - .execute(pool) + .execute(&mut *connection) .await? .rows_affected()) } @@ -1029,6 +1074,9 @@ pub async fn retry_match_batch( if event_ids.is_empty() { return Ok(0); } + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; Ok(sqlx::query( "UPDATE push_match_queue \ SET state='pending', claim_id=NULL, lease_until=NULL, next_attempt_at=$4 \ @@ -1038,7 +1086,7 @@ pub async fn retry_match_batch( .bind(claim_id) .bind(event_ids) .bind(next) - .execute(pool) + .execute(&mut *connection) .await? .rows_affected()) } @@ -1054,6 +1102,9 @@ pub async fn claim_due_wakes( lease_until: DateTime, ) -> Result> { let claim_id = Uuid::new_v4(); + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let rows = sqlx::query( r#" WITH candidates AS ( @@ -1101,7 +1152,7 @@ pub async fn claim_due_wakes( .bind(limit) .bind(claim_id) .bind(lease_until) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter().map(row_to_claimed_wake).collect() @@ -1118,6 +1169,9 @@ pub async fn revalidate_wake_for_send( id: Uuid, claim_id: Uuid, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let row = sqlx::query( r#" SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id, @@ -1149,7 +1203,7 @@ pub async fn revalidate_wake_for_send( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(row_to_claimed_wake) @@ -1166,6 +1220,9 @@ pub async fn complete_wake( id: Uuid, claim_id: Uuid, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'delivered', claim_id = NULL, lease_until = NULL \ @@ -1174,7 +1231,7 @@ pub async fn complete_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1187,6 +1244,9 @@ pub async fn retry_wake( claim_id: Uuid, next_attempt_at: DateTime, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'pending', next_attempt_at = $4, claim_id = NULL, lease_until = NULL \ @@ -1196,7 +1256,7 @@ pub async fn retry_wake( .bind(id) .bind(claim_id) .bind(next_attempt_at) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1208,6 +1268,9 @@ pub async fn fail_wake( id: Uuid, claim_id: Uuid, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'failed', claim_id = NULL, lease_until = NULL \ @@ -1216,7 +1279,7 @@ pub async fn fail_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1232,6 +1295,9 @@ pub async fn disable_endpoint_generation( installation_id: &str, generation: i64, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_leases SET endpoint_enabled = false, updated_at = now() \ WHERE community_id = $1 AND author = $2 AND installation_id = $3 \ @@ -1241,7 +1307,7 @@ pub async fn disable_endpoint_generation( .bind(author) .bind(installation_id) .bind(generation) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1256,6 +1322,9 @@ pub async fn prune_wake_outbox( community: CommunityId, before: DateTime, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "DELETE FROM push_wake_outbox o \ WHERE o.community_id = $1 AND o.created_at < $2 \ @@ -1268,7 +1337,7 @@ pub async fn prune_wake_outbox( ) .bind(community.as_uuid()) .bind(before) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } @@ -1463,10 +1532,12 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::migration; + use sqlx::postgres::PgPoolOptions; use std::sync::Arc; + use std::time::Duration; use tokio::sync::Barrier; async fn setup_pool() -> PgPool { @@ -1476,9 +1547,11 @@ mod tests { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); - migration::run_migrations(&pool) - .await - .expect("run migrations"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + migration::run_migrations(&pool) + .await + .expect("run migrations"); + } pool } @@ -2158,6 +2231,51 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn matcher_claim_and_load_support_size_one_pool() { + let setup = setup_pool().await; + sqlx::query("DELETE FROM push_match_queue") + .execute(&setup) + .await + .expect("drain matcher queue"); + let community = make_community(&setup).await; + activate(&setup, community, &[83; 32], "install", &[84; 32], 1).await; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "size one") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign event"); + crate::event::insert_event(&setup, community, &event, None) + .await + .expect("insert event"); + setup.close().await; + + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(250)) + .connect(&crate::test_support::database_url()) + .await + .expect("connect size-one matcher pool"); + let batch = tokio::time::timeout( + Duration::from_secs(2), + claim_due_match_batch(&pool, 16, Utc::now() + chrono::Duration::minutes(1)), + ) + .await + .expect("matcher must not self-starve on a size-one pool") + .expect("claim and source load must succeed") + .expect("seeded matcher job must be claimed"); + assert_eq!(batch.community, community); + assert_eq!(batch.jobs.len(), 1); + assert_eq!(batch.jobs[0].event.event.id, event.id); + + let ids = vec![event.id.as_bytes().to_vec()]; + assert_eq!( + complete_match_batch(&pool, community, batch.claim_id, &ids) + .await + .expect("complete size-one matcher batch"), + 1 + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn matcher_claim_is_exclusive_across_workers() { diff --git a/crates/buzz-db/src/store/reaction.rs b/crates/buzz-db/src/store/reaction.rs index 1f14adf176d..1c4ee19ead7 100644 --- a/crates/buzz-db/src/store/reaction.rs +++ b/crates/buzz-db/src/store/reaction.rs @@ -111,6 +111,11 @@ pub async fn add_reaction( emoji: &str, reaction_event_id: Option<&[u8]>, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query(ADD_REACTION_SQL) .bind(community.as_uuid()) .bind(event_created_at) @@ -118,7 +123,7 @@ pub async fn add_reaction( .bind(pubkey) .bind(emoji) .bind(reaction_event_id) - .execute(pool) + .execute(&mut *connection) .await?; // Three cases: @@ -173,7 +178,12 @@ pub async fn insert_reaction_event_with_thread_metadata( actor_pubkey: &[u8], emoji: &str, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let target_row = sqlx::query( "SELECT created_at FROM events \ @@ -236,6 +246,11 @@ pub async fn remove_reaction( pubkey: &[u8], emoji: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE reactions @@ -253,7 +268,7 @@ pub async fn remove_reaction( .bind(event_id) .bind(pubkey) .bind(emoji) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -267,6 +282,11 @@ pub async fn remove_reaction_by_source_event_id( community: CommunityId, reaction_event_id: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE reactions @@ -278,7 +298,7 @@ pub async fn remove_reaction_by_source_event_id( ) .bind(community.as_uuid()) .bind(reaction_event_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -293,6 +313,11 @@ pub async fn get_active_reaction_record( pubkey: &[u8], emoji: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" SELECT reaction_event_id @@ -311,7 +336,7 @@ pub async fn get_active_reaction_record( .bind(event_created_at) .bind(pubkey) .bind(emoji) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| -> Result { @@ -335,6 +360,11 @@ pub async fn set_reaction_event_id( emoji: &str, reaction_event_id: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE reactions @@ -353,7 +383,7 @@ pub async fn set_reaction_event_id( .bind(event_id) .bind(pubkey) .bind(emoji) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -376,6 +406,11 @@ pub async fn get_reactions( limit: u32, _cursor: Option<&str>, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; // Two-step query: first get the limited set of distinct emoji groups, // then fetch all rows for those groups. This ensures `limit` applies to // emoji groups (the API contract), not raw rows — so one busy emoji @@ -405,7 +440,7 @@ pub async fn get_reactions( .bind(event_id) .bind(event_created_at) .bind(limit as i64) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; // Group individual rows by emoji in Rust. @@ -466,6 +501,11 @@ pub async fn get_reactions_bulk( // Run one query per event. For typical message-list sizes (<=100 events) // this is acceptable; a single-query approach with dynamic IN clauses over // composite keys can be added later if needed. + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let mut entries = Vec::new(); for (event_id, event_created_at) in event_ids { @@ -484,7 +524,7 @@ pub async fn get_reactions_bulk( .bind(community.as_uuid()) .bind(*event_id) .bind(event_created_at) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; if rows.is_empty() { @@ -687,7 +727,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{ error::DbError, diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 438543da583..7662077911d 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -2043,7 +2043,7 @@ impl crate::Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 1424829933f..4bb48e121ca 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -116,7 +116,12 @@ pub async fn mint_relay_invite( // community-scoped database write. The trigger remains the final backstop, // but this typed guard keeps a quiescing community from surfacing as an // opaque SQLSTATE/HTTP 500 at the API boundary. - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; crate::deletion::DeletionStore::new(pool.clone()) .guard_transaction(&mut tx, community) .await?; @@ -172,6 +177,11 @@ const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000; /// expiry index makes old rows drain first without turning cleanup into an /// unbounded transaction. pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "DELETE FROM relay_invites \ WHERE (community_id, id) IN (\ @@ -184,7 +194,7 @@ pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> ) .bind(cutoff) .bind(RETENTION_SWEEP_BATCH_SIZE) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) @@ -216,7 +226,12 @@ pub async fn claim_relay_invite( claimer_pubkey: &str, policy_version: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( @@ -429,29 +444,21 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::relay_members::is_relay_member; use sha2::Digest; use sqlx::PgPool; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_pool() -> PgPool { - PgPool::connect(&test_database_url()) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } - fn test_database_url() -> String { - std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_owned()) - } - async fn create_scratch_database(prefix: &str) -> (PgPool, String, String) { - let admin_url = test_database_url(); + let admin_url = crate::test_support::database_url(); let admin = PgPool::connect(&admin_url) .await .expect("connect to test database server"); diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 0a20b011ebd..ecde3924fef 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -32,7 +32,8 @@ 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?; + let mut conn = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; is_relay_member_on(&mut conn, community, pubkey).await } @@ -57,12 +58,14 @@ pub(crate) async fn is_relay_member_on( /// (`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 mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; 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) + .fetch_optional(&mut *connection) .await?; Ok(row.is_some()) } @@ -73,13 +76,15 @@ pub async fn get_relay_member( community: CommunityId, pubkey: &str, ) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let row = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ FROM relay_members WHERE community_id = $1 AND pubkey = $2", ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|r| -> std::result::Result { @@ -97,12 +102,26 @@ pub async fn get_relay_member( /// Returns all relay members of `community` ordered by `created_at` ascending. pub async fn list_relay_members(pool: &PgPool, community: CommunityId) -> Result> { + list_relay_members_with_operation( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await +} + +async fn list_relay_members_with_operation( + pool: &PgPool, + community: CommunityId, + operation: observability::WriterOperation, +) -> Result> { + let mut connection = observability::acquire_writer(pool, operation).await?; let rows = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ FROM relay_members WHERE community_id = $1 ORDER BY created_at ASC", ) .bind(community.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -131,6 +150,8 @@ pub async fn add_relay_member( role: &str, added_by: Option<&str>, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", @@ -139,7 +160,7 @@ pub async fn add_relay_member( .bind(pubkey) .bind(role) .bind(added_by) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -156,7 +177,9 @@ pub async fn claim_relay_membership( role: &str, policy_version: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, 'invite') \ @@ -193,6 +216,8 @@ pub async fn has_join_policy_acceptance( pubkey: &str, policy_version: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let row = sqlx::query( "SELECT 1 FROM join_policy_acceptances \ WHERE community_id = $1 AND pubkey = $2 AND policy_version = $3", @@ -200,7 +225,7 @@ pub async fn has_join_policy_acceptance( .bind(community.as_uuid()) .bind(pubkey) .bind(policy_version) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.is_some()) } @@ -228,13 +253,15 @@ pub async fn remove_relay_member( community: CommunityId, pubkey: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "DELETE FROM relay_members \ WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", ) .bind(community.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() > 0 { @@ -246,7 +273,7 @@ pub async fn remove_relay_member( let exists = 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(&mut *connection) .await?; if exists.is_some() { @@ -275,13 +302,15 @@ pub async fn remove_relay_member_if_role( pubkey: &str, expected_role: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", ) .bind(community.as_uuid()) .bind(pubkey) .bind(expected_role) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() > 0 { @@ -293,7 +322,7 @@ pub async fn remove_relay_member_if_role( let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -320,6 +349,8 @@ pub async fn update_relay_member_role( pubkey: &str, new_role: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "UPDATE relay_members SET role = $1, updated_at = now() \ WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", @@ -327,7 +358,7 @@ pub async fn update_relay_member_role( .bind(new_role) .bind(community.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -351,9 +382,25 @@ pub async fn bootstrap_owner( pool: &PgPool, community: CommunityId, owner_pubkey: &str, +) -> Result<()> { + bootstrap_owner_with_operation( + pool, + community, + owner_pubkey, + observability::WriterOperation::Bootstrap, + ) + .await +} + +async fn bootstrap_owner_with_operation( + pool: &PgPool, + community: CommunityId, + owner_pubkey: &str, + operation: observability::WriterOperation, ) -> Result<()> { let pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = pool.begin().await?; + let connection = observability::acquire_writer(pool, operation).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 1. Upsert the configured owner for this community. sqlx::query( @@ -472,7 +519,9 @@ pub async fn transfer_ownership( ) -> Result { let pubkey = new_owner_pubkey.to_ascii_lowercase(); let expected_owner = expected_owner_pubkey.to_ascii_lowercase(); - let mut tx = pool.begin().await?; + let connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 1. Serialize on the transferee so concurrent transfers to the same // recipient cannot both pass the ownership count check. @@ -573,12 +622,14 @@ pub async fn transfer_ownership( /// The empty-table guard prevents re-adding members that were intentionally /// removed by an admin after the initial backfill. pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Bootstrap).await?; // Check if pubkey_allowlist table exists. let exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')", ) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; if !exists { @@ -591,7 +642,7 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)") .bind(community.as_uuid()) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; if has_members { @@ -606,7 +657,7 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R ON CONFLICT (community_id, pubkey) DO NOTHING", ) .bind(community.as_uuid()) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) @@ -624,7 +675,14 @@ impl Db { #[datastore_span(name = "is_relay_member", system = "postgresql")] pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { let path = "relay_membership"; - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::Authorization, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => { match is_relay_member_on(&mut tx, community, pubkey).await { Ok(is_member) => { @@ -738,6 +796,18 @@ impl Db { bootstrap_owner(&self.pool, community, owner_pubkey).await } + /// Ensure an owner during operator-driven community provisioning. + #[datastore_span(name = "provision_owner", system = "postgresql")] + pub async fn provision_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + bootstrap_owner_with_operation( + &self.pool, + community, + owner_pubkey, + observability::WriterOperation::Authorization, + ) + .await + } + /// Returns `true` if any member of `community` holds the `admin` or /// `owner` role. #[datastore_span(name = "has_admin_or_owner", system = "postgresql")] @@ -784,23 +854,80 @@ impl Db { name = "nip43_membership_snapshot_needs_reconciliation", system = "postgresql" )] + #[deprecated( + note = "use nip43_membership_snapshot_needs_reconciliation_for_bootstrap or nip43_membership_snapshot_needs_reconciliation_for_maintenance" + )] pub async fn nip43_membership_snapshot_needs_reconciliation( &self, community_id: CommunityId, relay_pubkey: &nostr::PublicKey, ) -> Result { - let snapshot = self - .query_events(&crate::event::EventQuery { + self.nip43_membership_snapshot_needs_reconciliation_with_operation( + community_id, + relay_pubkey, + observability::WriterOperation::Maintenance, + ) + .await + } + + /// Startup-attributed variant of the NIP-43 snapshot comparison. + #[datastore_span( + name = "nip43_membership_snapshot_needs_reconciliation_for_bootstrap", + system = "postgresql" + )] + pub async fn nip43_membership_snapshot_needs_reconciliation_for_bootstrap( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> Result { + self.nip43_membership_snapshot_needs_reconciliation_with_operation( + community_id, + relay_pubkey, + observability::WriterOperation::Bootstrap, + ) + .await + } + + /// Periodic maintenance variant of the NIP-43 snapshot comparison. + #[datastore_span( + name = "nip43_membership_snapshot_needs_reconciliation_for_maintenance", + system = "postgresql" + )] + pub async fn nip43_membership_snapshot_needs_reconciliation_for_maintenance( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> Result { + self.nip43_membership_snapshot_needs_reconciliation_with_operation( + community_id, + relay_pubkey, + observability::WriterOperation::Maintenance, + ) + .await + } + + async fn nip43_membership_snapshot_needs_reconciliation_with_operation( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + operation: observability::WriterOperation, + ) -> Result { + let snapshot = crate::event::query_events_with_operation( + &self.pool, + &crate::event::EventQuery { kinds: Some(vec![buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32]), pubkey: Some(relay_pubkey.to_bytes().to_vec()), global_only: true, limit: Some(1), ..crate::event::EventQuery::for_community(community_id) - }) - .await? - .into_iter() - .next(); - let members = self.list_relay_members(community_id).await?; + }, + operation, + ) + .await? + .into_iter() + .next(); + let members = + list_relay_members_with_operation(&self.pool, community_id, operation).await?; let Some(snapshot) = snapshot else { return Ok(true); @@ -968,7 +1095,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { #[test] fn owner_limit_defaults_when_unset_or_invalid() { assert_eq!( @@ -1359,8 +1486,8 @@ mod tests { let owner = test_pubkey(); let transferee = test_pubkey(); - // Give the transferee 3 communities (the max). - for _ in 0..3 { + // Fill the configured default ownership limit. + for _ in 0..MAX_COMMUNITIES_PER_OWNER { let c = make_test_community(&pool).await; bootstrap_owner(&pool, c, &transferee) .await diff --git a/crates/buzz-db/src/store/relay_operators.rs b/crates/buzz-db/src/store/relay_operators.rs index 3670a2f142b..41b45a9ac68 100644 --- a/crates/buzz-db/src/store/relay_operators.rs +++ b/crates/buzz-db/src/store/relay_operators.rs @@ -97,7 +97,12 @@ pub async fn upsert( added_by: &[u8], config_operator_exists: bool, ) -> Result<()> { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // A demotion to moderator can drop the effective-operator count; serialize // it against every other operator-removing mutation via the roster-wide @@ -193,7 +198,12 @@ pub async fn remove( actor: &[u8], config_operator_exists: bool, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // Serialize against every other operator-removing mutation before the // delete so the post-delete count reflects a stable roster. @@ -235,11 +245,16 @@ pub async fn remove( /// Fetch one relay operator/moderator row by pubkey. pub async fn get(pool: &PgPool, pubkey: &[u8]) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT pubkey, role, added_by, created_at FROM relay_operators WHERE pubkey = $1", ) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map( @@ -258,10 +273,15 @@ pub async fn get(pool: &PgPool, pubkey: &[u8]) -> Result Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( "SELECT pubkey, role, added_by, created_at FROM relay_operators ORDER BY created_at ASC", ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -326,7 +346,7 @@ impl crate::Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; diff --git a/crates/buzz-db/src/store/reminder.rs b/crates/buzz-db/src/store/reminder.rs index 20f503f4008..2d2dde18c11 100644 --- a/crates/buzz-db/src/store/reminder.rs +++ b/crates/buzz-db/src/store/reminder.rs @@ -239,7 +239,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::event::insert_event; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 9b575b6ea18..5985d22b6d0 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -586,7 +586,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{event, migration, replaceable}; use sqlx::postgres::PgPoolOptions; @@ -601,6 +601,11 @@ mod tests { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") { + migration::run_migrations(&pool) + .await + .expect("apply migration schema"); + } Db::from_pool(pool) } @@ -994,7 +999,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn nip_rs_transaction_operation_restores_hard_delete_opt_in() { + async fn migration_schema_nip_rs_transaction_operation_restores_hard_delete_opt_in() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; @@ -1034,7 +1039,7 @@ mod tests { ); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin caller transaction"); let result = db @@ -1104,7 +1109,10 @@ mod tests { .1 ); - let mut tx = db.begin_transaction().await.expect("begin replacement tx"); + let mut tx = db + .begin_event_write_transaction() + .await + .expect("begin replacement tx"); let outcome = db .replace_parameterized_event_in_transaction( &mut tx, @@ -1138,7 +1146,7 @@ mod tests { assert_eq!(live_id, old.id.as_bytes().to_vec()); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin stale revision tx"); let mismatch = db @@ -1172,7 +1180,7 @@ mod tests { .sign_with_keys(&keys) .expect("sign missing project"); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin missing revision tx"); let missing_result = db @@ -1250,7 +1258,7 @@ mod tests { .expect("install failure injection"); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin caller transaction"); let error = db @@ -1331,7 +1339,7 @@ mod tests { .expect("soft-delete duplicate row"); let mut seed_tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin seed transaction"); let (_, was_inserted) = @@ -1342,7 +1350,7 @@ mod tests { seed_tx.commit().await.expect("commit older live head"); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin caller transaction"); let result = db @@ -1444,7 +1452,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn mesh_status_replacement_keeps_one_physical_row() { + async fn migration_schema_mesh_status_replacement_keeps_one_physical_row() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; @@ -1607,7 +1615,8 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() { + async fn migration_schema_nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction( + ) { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index d7a2d239eff..a38ac2b0380 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -11,6 +11,23 @@ use uuid::Uuid; use buzz_datastore_tracing::datastore_span; +async fn acquire_event_write_connection( + pool: &PgPool, +) -> Result> { + Ok(crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?) +} + +async fn begin_event_write_transaction( + pool: &PgPool, +) -> Result> { + let connection = acquire_event_write_connection(pool).await?; + Ok(sqlx::Transaction::begin(connection, None).await?) +} + use buzz_core::CommunityId; use crate::{ @@ -131,7 +148,7 @@ pub async fn insert_thread_metadata( depth: i32, broadcast: bool, ) -> Result<()> { - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; let result = sqlx::query( r#" @@ -259,6 +276,7 @@ pub async fn increment_reply_count( parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // Always bump the parent's direct reply count and last-reply timestamp. sqlx::query( r#" @@ -270,7 +288,7 @@ pub async fn increment_reply_count( ) .bind(community_id.as_uuid()) .bind(parent_event_id) - .execute(pool) + .execute(&mut *connection) .await?; // Always bump root's descendant_count, regardless of whether root == parent. @@ -284,7 +302,7 @@ pub async fn increment_reply_count( ) .bind(community_id.as_uuid()) .bind(root_id) - .execute(pool) + .execute(&mut *connection) .await?; } @@ -300,6 +318,7 @@ pub async fn decrement_reply_count( parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // Always decrement the parent's direct reply count (floor at 0). sqlx::query( r#" @@ -310,7 +329,7 @@ pub async fn decrement_reply_count( ) .bind(community_id.as_uuid()) .bind(parent_event_id) - .execute(pool) + .execute(&mut *connection) .await?; // Always decrement root's descendant_count, regardless of whether root == parent. @@ -324,7 +343,7 @@ pub async fn decrement_reply_count( ) .bind(community_id.as_uuid()) .bind(root_id) - .execute(pool) + .execute(&mut *connection) .await?; } @@ -355,7 +374,11 @@ pub async fn get_thread_replies( limit: u32, cursor: Option<&[u8]>, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; get_thread_replies_on( &mut conn, community_id, @@ -520,6 +543,11 @@ pub async fn get_thread_summary( community_id: CommunityId, event_id: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" SELECT reply_count, descendant_count, last_reply_at @@ -530,7 +558,7 @@ pub async fn get_thread_summary( ) .bind(community_id.as_uuid()) .bind(event_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; let row = match row { @@ -563,7 +591,7 @@ pub async fn get_thread_summary( ) .bind(community_id.as_uuid()) .bind(event_id) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let participants: Vec> = participant_rows @@ -599,7 +627,11 @@ pub async fn get_channel_window( cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, ) -> Result { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; get_channel_window_on( &mut conn, community_id, @@ -811,6 +843,11 @@ pub async fn get_thread_metadata_by_event( community_id: CommunityId, event_id: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" SELECT @@ -830,7 +867,7 @@ pub async fn get_thread_metadata_by_event( ) .bind(community_id.as_uuid()) .bind(event_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; let row = match row { @@ -935,8 +972,13 @@ impl Db { ), None => ("thread_head", RoutePredicate::Bounded), }; - if let RouteDecision::Replica(mut tx, entry, reason) = - self.route_read(path, predicate).await + if let RouteDecision::Replica(mut tx, entry, reason) = self + .route_read( + path, + predicate, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await { match crate::thread::get_thread_replies_on( &mut tx, @@ -1062,6 +1104,7 @@ impl Db { .route_read( path, RoutePredicate::from_channel_cursor(channel_id, &cursor), + crate::observability::ReaderOperation::SubscriptionHistory, ) .await { @@ -1152,7 +1195,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{ channel::{ChannelType, ChannelVisibility}, diff --git a/crates/buzz-db/src/store/usage.rs b/crates/buzz-db/src/store/usage.rs index 97235f0b26e..9d557c8b18a 100644 --- a/crates/buzz-db/src/store/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -43,8 +43,10 @@ impl UsageMetricsLeader { /// Total number of communities registered on this relay. pub async fn community_count(pool: &PgPool) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM communities") - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; Ok(row) } @@ -64,6 +66,8 @@ pub struct CommunityUserCounts { /// /// Agent discriminator: `agent_owner_pubkey IS NOT NULL`. pub async fn user_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; // Single GROUP BY query; two conditional SUMs avoid two round-trips. let rows = sqlx::query_as::<_, (Uuid, i64, i64)>( r#" @@ -76,7 +80,7 @@ pub async fn user_counts(pool: &PgPool) -> Result> { GROUP BY community_id "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -102,6 +106,8 @@ pub struct CommunityChannelCount { /// Return non-deleted channel counts per community per type. pub async fn channel_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, String, i64)>( r#" SELECT community_id, channel_type::text, COUNT(*) AS count @@ -110,7 +116,7 @@ pub async fn channel_counts(pool: &PgPool) -> Result> GROUP BY community_id, channel_type "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -136,6 +142,8 @@ pub struct CommunityMessageCount { /// Return non-deleted kind=9 event counts per community. pub async fn message_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, i64)>( r#" SELECT community_id, COUNT(*) AS count @@ -144,7 +152,7 @@ pub async fn message_counts(pool: &PgPool) -> Result> GROUP BY community_id "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -169,6 +177,8 @@ pub struct CommunityMemberCount { /// Return relay-member counts per community per role. pub async fn relay_member_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, String, i64)>( r#" SELECT community_id, role::text, COUNT(*) AS count @@ -176,7 +186,7 @@ pub async fn relay_member_counts(pool: &PgPool) -> Result Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, String, i64)>( r#" SELECT community_id, status::text, COUNT(*) AS count @@ -209,7 +221,7 @@ pub async fn workflow_counts(pool: &PgPool) -> Result Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, i64)>( r#" SELECT community_id, COUNT(*) AS count @@ -240,7 +254,7 @@ pub async fn git_repo_counts(pool: &PgPool) -> Result GROUP BY community_id "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -280,6 +294,8 @@ pub async fn active_user_counts( pool: &PgPool, interval_sql: &'static str, ) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; // LEFT JOIN users: pubkeys with no row have u.* = NULL. // Three-way classification: // human — row exists (u.pubkey IS NOT NULL) and agent_owner_pubkey IS NULL @@ -304,7 +320,7 @@ pub async fn active_user_counts( "# ); let rows = sqlx::query_as::<_, (Uuid, i64, i64, i64)>(sqlx::AssertSqlSafe(sql)) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -334,6 +350,8 @@ pub async fn active_channel_counts( pool: &PgPool, interval_sql: &'static str, ) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let sql = format!( r#" SELECT community_id, COUNT(DISTINCT channel_id) AS count @@ -346,7 +364,7 @@ pub async fn active_channel_counts( "# ); let rows = sqlx::query_as::<_, (Uuid, i64)>(sqlx::AssertSqlSafe(sql)) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -370,8 +388,16 @@ pub struct CommunityHost { /// Fetch all community id → host mappings in one query. pub async fn community_hosts(pool: &PgPool) -> Result> { + community_hosts_with_operation(pool, observability::WriterOperation::Maintenance).await +} + +async fn community_hosts_with_operation( + pool: &PgPool, + operation: observability::WriterOperation, +) -> Result> { + let mut connection = observability::acquire_writer(pool, operation).await?; let rows = sqlx::query_as::<_, (Uuid, String)>("SELECT id, host FROM communities") - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows .into_iter() @@ -391,8 +417,11 @@ impl Db { &self, lock_key: i64, ) -> Result> { - let mut connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + let mut connection = observability::acquire_writer_with_legacy_metrics( + &self.pool, + observability::WriterOperation::Maintenance, + ) + .await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) @@ -473,20 +502,24 @@ impl Db { pub async fn usage_community_hosts(&self) -> Result> { community_hosts(&self.pool).await } + + /// Return community host mappings during startup bootstrap work. + #[datastore_span(name = "bootstrap_community_hosts", system = "postgresql")] + pub async fn bootstrap_community_hosts(&self) -> Result> { + community_hosts_with_operation(&self.pool, observability::WriterOperation::Bootstrap).await + } } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use buzz_core::CommunityId; use nostr::Keys; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn get_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } @@ -497,7 +530,7 @@ mod tests { .execute(admin) .await .expect("create scratch db"); - let base = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let base = crate::test_support::database_url(); let idx = base.rfind('/').expect("db url has a path segment"); let scratch_url = format!("{}/{}", &base[..idx], name); let pool = PgPool::connect(&scratch_url) @@ -525,7 +558,7 @@ mod tests { // Postgres advisory locks are per-database; hardcoding the production // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB // races any live buzz-relay on the same database (see #3619). - let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let admin_url = crate::test_support::database_url(); let admin = PgPoolOptions::new() .max_connections(1) .connect(&admin_url) diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 140a722a21b..759e916e4b4 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -42,6 +42,22 @@ pub struct UserSearchProfile { /// The `true` case is the reliable signal for "user was just registered" — used /// by callers to increment `buzz_users_created_total`. pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result { + ensure_user_with_operation( + pool, + community_id, + pubkey, + crate::observability::WriterOperation::EventWrite, + ) + .await +} + +async fn ensure_user_with_operation( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let result = sqlx::query( r#" INSERT INTO users (community_id, pubkey) @@ -51,7 +67,7 @@ pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8] ) .bind(community_id.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -296,6 +312,24 @@ pub async fn set_agent_owner( agent_pubkey: &[u8], owner_pubkey: &[u8], ) -> Result { + set_agent_owner_with_operation( + pool, + community_id, + agent_pubkey, + owner_pubkey, + crate::observability::WriterOperation::EventWrite, + ) + .await +} + +async fn set_agent_owner_with_operation( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( @@ -304,7 +338,7 @@ pub async fn set_agent_owner( .bind(owner_pubkey) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() == 0 { @@ -313,7 +347,7 @@ pub async fn set_agent_owner( let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; if exists.is_none() { return Err(crate::error::DbError::NotFound( @@ -334,12 +368,17 @@ pub async fn get_agent_channel_policy( community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2"#, ) .bind(community_id.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|r| -> Result<(String, Option>)> { @@ -359,13 +398,18 @@ pub async fn is_agent_owner( target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query_scalar::<_, bool>( "SELECT agent_owner_pubkey = $3 FROM users WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL", ) .bind(community_id.as_uuid()) .bind(target_pubkey) .bind(actor_pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.unwrap_or(false)) } @@ -411,6 +455,23 @@ impl Db { crate::user::ensure_user(&self.pool, community_id, pubkey).await } + /// Ensure a principal while materializing an authenticated NIP-OA + /// authorization relationship. + #[datastore_span(name = "ensure_user_for_authorization", system = "postgresql")] + pub async fn ensure_user_for_authorization( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result { + ensure_user_with_operation( + &self.pool, + community_id, + pubkey, + crate::observability::WriterOperation::Authorization, + ) + .await + } + /// Get a single user record by pubkey. #[datastore_span(name = "get_user", system = "postgresql")] pub async fn get_user( @@ -478,6 +539,25 @@ impl Db { crate::user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await } + /// Materialize an authenticated NIP-OA agent-owner relationship under + /// authorization attribution. + #[datastore_span(name = "set_agent_owner_for_authorization", system = "postgresql")] + pub async fn set_agent_owner_for_authorization( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + ) -> Result { + set_agent_owner_with_operation( + &self.pool, + community_id, + agent_pubkey, + owner_pubkey, + crate::observability::WriterOperation::Authorization, + ) + .await + } + /// Get the channel_add_policy and agent_owner_pubkey for a user. #[datastore_span(name = "get_agent_channel_policy", system = "postgresql")] pub async fn get_agent_channel_policy( @@ -512,15 +592,13 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::Db; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_db() -> Db { - let pool = PgPool::connect(TEST_DB_URL) + let pool = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB"); Db::from_pool(pool) diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 0ae1b623764..3ceed9ea32e 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -1686,7 +1686,7 @@ impl Db { // -- Tests -------------------------------------------------------------------- #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use chrono::TimeZone; diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs new file mode 100644 index 00000000000..7699313d636 --- /dev/null +++ b/crates/buzz-db/src/test_support.rs @@ -0,0 +1,9 @@ +const DEFAULT_DATABASE_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + +/// Resolve the database URL shared by PostgreSQL-backed unit tests. +pub(crate) fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("TEST_DATABASE_URL")) + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) +} diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 9e37186009f..832b05c56f2 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -79,3 +79,549 @@ fn relay_admin_db_wrappers_have_exactly_one_datastore_span() { ); } } + +#[test] +fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { + let observability = include_str!("../src/runtime/observability.rs"); + assert!(observability.contains("enum PoolOperation")); + assert!(observability.contains("pub(crate) enum WriterOperation")); + assert!(observability.contains("pub(crate) enum ReaderOperation")); + assert!(observability.contains("Self::WriterAuthentication")); + assert!(observability.contains("Self::ReaderSubscriptionHistory")); + assert!(observability.contains("pub(crate) async fn acquire_writer(")); + assert!(observability.contains("pub(super) async fn acquire_reader_with_legacy_metrics(")); + assert!(observability.contains("static POOL_WAITERS: [Mutex")); + assert!(!observability.contains("AtomicU64")); + assert!(!observability.contains("DbOperation::Other")); + assert!(!observability.contains("\"other\"")); + assert!(!observability.contains("buzz_db_pool_acquire_timeouts_total")); + assert!(!observability.contains("\"result\" =>")); + let legacy_transaction = observability + .split_once("pub(crate) async fn begin_transaction(") + .expect("observability must expose attributed transaction acquisition") + .1 + .split_once("pub(crate) async fn observe_advisory_lock") + .expect("transaction acquisition must precede advisory-lock observation") + .0; + assert!(legacy_transaction.contains("acquire_writer_with_legacy_metrics(")); + + let runtime = include_str!("../src/runtime/mod.rs"); + assert!(runtime.contains("observability::acquire_writer_until(")); + assert!(runtime.contains("WriterOperation::Readiness")); + assert!(runtime.contains("WriterOperation::EventWrite")); + assert!(runtime.contains("ReaderOperation::Bootstrap")); + assert!(runtime.contains("pub async fn begin_event_write_transaction")); + let reader_boot = runtime + .split_once("async fn read_pool_boot_ping_once(") + .expect("runtime must expose the reader boot probe") + .1 + .split_once("#[cfg(test)]") + .expect("reader boot probe must precede its test seam") + .0; + assert!(reader_boot.contains("acquire_reader_with_legacy_metrics(")); + let routed_reader = runtime + .split_once("async fn proved_reader(") + .expect("runtime must expose the routed-reader checkout") + .1 + .split_once("async fn reader_aurora_capability_on(") + .expect("routed-reader checkout must precede capability probing") + .0; + assert!(routed_reader.contains("acquire_reader_with_legacy_metrics(read_pool, operation)")); + let event_write_transaction = runtime + .split_once("pub async fn begin_event_write_transaction(") + .expect("runtime must expose the legacy event-write transaction seam") + .1 + .split_once("pub async fn insert_event_with_serving_write_guard(") + .expect("legacy event-write transaction must precede guarded writes") + .0; + assert!(event_write_transaction.contains("acquire_writer_with_legacy_metrics(")); + + let migration = include_str!("../src/runtime/migration.rs"); + let migration_lock = migration + .split_once("pub(crate) async fn with_exclusive_schema_destruction_lock") + .expect("migration must expose the schema-safety acquisition seam") + .1 + .split_once("async fn reject_legacy_nip_rs_cardinality_ambiguity") + .expect("schema-safety acquisition must precede migration validation") + .0; + assert!(migration_lock.contains("acquire_writer_with_legacy_metrics(")); + + let allowlist = include_str!("../src/store/allowlist.rs"); + assert!(allowlist.contains("WriterOperation::Authentication")); + assert!(allowlist.contains("WriterOperation::Authorization")); + assert!(!allowlist.contains("fetch_one(&self.pool)")); + + let event = include_str!("../src/store/event.rs"); + assert!(event.contains("query_events_with_operation")); + assert!(event.contains("WriterOperation::Authorization")); + assert!(event.contains("WriterOperation::SubscriptionHistory")); + assert!(event.contains("ReaderOperation::SubscriptionHistory")); + let backfill_d_tags = event + .split_once("pub async fn backfill_d_tags") + .expect("event store must expose the startup d-tag backfill") + .1 + .split_once("/// Soft-delete NIP-29 discovery events") + .expect("d-tag backfill must precede discovery deletion") + .0; + assert!(backfill_d_tags.contains("WriterOperation::Bootstrap")); + assert!(backfill_d_tags.contains("execute(&mut *connection)")); + let soft_delete_discovery = event + .split_once("pub async fn soft_delete_discovery_events") + .expect("event store must expose discovery-event deletion") + .1 + .split_once("\n}\n\n#[cfg(test)]") + .expect("discovery deletion must end the production Db implementation") + .0; + assert!(soft_delete_discovery.contains("WriterOperation::EventWrite")); + assert!(soft_delete_discovery.contains("execute(&mut *connection)")); + + let side_effects = include_str!("../../buzz-relay/src/handlers/side_effects.rs"); + assert!(side_effects.contains("query_events_for_event_write")); + assert!(side_effects.contains("query_events_for_bootstrap")); + assert!(side_effects.contains(".list_channels_for_bootstrap(")); + + let deletion = include_str!("../src/store/deletion.rs"); + let public_serving_catalog = deletion + .split_once("pub async fn validate_serving_catalog(&self)") + .expect("deletion store must preserve its public serving-catalog API") + .1 + .split_once("async fn validate_serving_catalog_on") + .expect("public serving-catalog validation must delegate to its connection helper") + .0; + assert!(public_serving_catalog.contains("WriterOperation::Bootstrap")); + assert!(public_serving_catalog.contains("observability::acquire_writer(")); + assert!(public_serving_catalog.contains("validate_serving_catalog_on")); + assert!(!public_serving_catalog.contains("self.pool.acquire()")); + + let thread = include_str!("../src/store/thread.rs"); + let thread_metadata = thread + .split_once("pub async fn get_thread_metadata_by_event(") + .expect("thread store must expose metadata lookup") + .1 + .split_once("// -- Db API") + .expect("metadata lookup must precede the Db wrapper section") + .0; + assert!(thread_metadata.contains("WriterOperation::EventWrite")); + assert!(thread_metadata.contains("fetch_optional(&mut *connection)")); + assert!(!thread_metadata.contains("fetch_optional(pool)")); + + let channel = include_str!("../src/store/channel.rs"); + assert!(channel.contains("async fn begin_event_write_transaction(")); + assert!(channel.contains("async fn acquire_event_write_connection(")); + for (start, end, expected) in [ + ( + "pub async fn create_channel(\n", + "/// Creates a channel with a client-supplied UUID", + "begin_event_write_transaction(pool)", + ), + ( + "pub async fn create_channel_with_id(\n", + "/// Fetches a channel record by `(community_id, id)`", + "begin_event_write_transaction(pool)", + ), + ( + "pub async fn update_channel(\n", + "/// Sets the topic for a channel", + "begin_event_write_transaction(pool)", + ), + ( + "pub async fn set_topic(\n", + "/// Sets the purpose for a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn set_purpose(\n", + "/// Archives a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn archive_channel(\n", + "/// Unarchives a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn unarchive_channel(\n", + "/// Soft-delete a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn soft_delete_channel(\n", + "/// Archive ephemeral channels", + "acquire_event_write_connection(pool)", + ), + ] { + let function = channel + .split_once(start) + .unwrap_or_else(|| panic!("missing channel seam {start}")) + .1 + .split_once(end) + .unwrap_or_else(|| panic!("channel seam {start} must precede {end}")) + .0; + assert!( + function.contains(expected), + "channel seam {start} must use {expected}" + ); + assert!(!function.contains("pool.begin().await")); + assert!(!function.contains(".execute(pool)")); + assert!(!function.contains(".fetch_optional(pool)")); + } + let get_channel = channel + .split_once("async fn get_channel_with_operation(") + .expect("channel store must route shared lookups through caller-owned intent") + .1 + .split_once("/// Returns the canvas content") + .expect("channel lookup helper must precede canvas reads") + .0; + assert!(get_channel.contains("acquire_writer(pool, operation)")); + assert!(get_channel.contains("fetch_optional(&mut *connection)")); + assert!(!get_channel.contains("fetch_optional(pool)")); + assert!(channel.contains("pub async fn get_channel_for_event_write(")); + let list_channels = channel + .split_once("async fn list_channels_with_operation(") + .expect("channel listing must accept caller-owned intent") + .1 + .split_once("/// A channel archived by the ephemeral-channel reaper") + .expect("channel listing must precede ephemeral-channel types") + .0; + assert!(list_channels.contains("acquire_writer(pool, operation)")); + assert!(list_channels.contains("fetch_all(&mut *connection)")); + assert!(!list_channels.contains("fetch_all(pool)")); + assert!(channel.contains("pub async fn list_channels_for_bootstrap(")); + + let channel_members = include_str!("../src/store/channel_members.rs"); + assert!(channel_members.contains("async fn get_members_with_operation(")); + assert!(channel_members.contains("pub async fn get_members_for_event_write(")); + assert!(channel_members.contains("async fn get_users_bulk_with_operation(")); + assert!(channel_members.contains("pub async fn get_users_bulk_for_event_write(")); + + let huddle_link = event + .split_once("async fn huddle_started_link_exists_with_operation(") + .expect("huddle link lookup must accept caller-owned intent") + .1 + .split_once("/// Insert a Nostr event") + .expect("huddle link lookup must precede event insertion") + .0; + assert!(huddle_link.contains("acquire_writer(pool, operation)")); + assert!(event.contains("pub async fn huddle_started_link_exists_for_event_write(")); + let ingest = include_str!("../../buzz-relay/src/handlers/ingest.rs"); + assert!(ingest.contains(".huddle_started_link_exists_for_event_write(")); + let audio = include_str!("../../buzz-relay/src/audio/handler.rs"); + assert!(audio.contains(".huddle_started_link_exists(")); + + let workflow_sink = include_str!("../../buzz-relay/src/workflow_sink.rs"); + assert!(workflow_sink.contains(".get_members_for_event_write(")); + assert!(workflow_sink.contains(".get_users_bulk_for_event_write(")); + + for write_caller in [ + include_str!("../../buzz-relay/src/handlers/side_effects.rs"), + include_str!("../../buzz-relay/src/handlers/ingest.rs"), + include_str!("../../buzz-relay/src/handlers/command_executor.rs"), + workflow_sink, + ] { + assert!(!write_caller.contains(".get_channel(")); + assert!(write_caller.contains(".get_channel_for_event_write(")); + } + + let user = include_str!("../src/store/user.rs"); + let agent_channel_policy = user + .split_once("pub async fn get_agent_channel_policy(") + .expect("user store must expose get_agent_channel_policy") + .1 + .split_once("/// Check whether `actor_pubkey`") + .expect("agent policy lookup must precede owner lookup") + .0; + assert!(agent_channel_policy.contains("WriterOperation::Authorization")); + assert!(agent_channel_policy.contains("fetch_optional(&mut *connection)")); + assert!(!agent_channel_policy.contains("fetch_optional(pool)")); + let is_agent_owner = user + .split_once("pub async fn is_agent_owner(") + .expect("user store must expose is_agent_owner") + .1 + .split_once("/// Set the channel_add_policy") + .expect("is_agent_owner must precede set_agent_channel_policy") + .0; + assert!(is_agent_owner.contains("WriterOperation::Authorization")); + assert!(is_agent_owner.contains("acquire_writer(")); + assert!(is_agent_owner.contains("fetch_optional(&mut *connection)")); + assert!(!is_agent_owner.contains("fetch_optional(pool)")); + + let moderation = include_str!("../src/store/moderation.rs"); + let restriction_state = moderation + .split_once("pub async fn restriction_state(") + .expect("moderation store must expose restriction_state") + .1 + .split_once("/// Fetch the full ban/timeout row") + .expect("restriction state must precede full ban reads") + .0; + assert!(restriction_state.contains("WriterOperation::Authorization")); + assert!(restriction_state.contains("fetch_optional(&mut *connection)")); + assert!(!restriction_state.contains("fetch_optional(pool)")); + + let community_store = include_str!("../src/store/community.rs"); + let ensure_community = community_store + .split_once("pub async fn ensure_configured_community(") + .expect("community store must expose ensure_configured_community") + .1 + .split_once("/// Atomically creates a community") + .expect("configured-community helpers must precede community creation") + .0; + assert!(ensure_community.contains("WriterOperation::Authorization")); + assert!(ensure_community.contains("WriterOperation::Bootstrap")); + assert!(ensure_community.contains("ensure_configured_community_with_operation")); + assert!(ensure_community.contains("acquire_writer(&self.pool, operation)")); + assert!(ensure_community.contains("fetch_optional(&mut *connection)")); + let management_lookup = community_store + .split_once("pub async fn lookup_community_by_host_for_management(") + .expect("community store must expose management host lookup") + .1 + .split_once("/// Lists communities where") + .expect("management lookup must precede owner listing") + .0; + assert!(management_lookup.contains("WriterOperation::Authorization")); + assert!(management_lookup.contains("fetch_optional(&mut *connection)")); + assert!(!management_lookup.contains("fetch_optional(&self.pool)")); + let community_production = community_store + .split("\n#[cfg(test)]") + .next() + .expect("community production source"); + for required in [ + "WriterOperation::TenantResolution", + "WriterOperation::Authorization", + "WriterOperation::SubscriptionHistory", + "WriterOperation::EventWrite", + ] { + assert!( + community_production.contains(required), + "community P0 paths must include {required} attribution" + ); + } + assert!(!community_production.contains("self.pool.begin().await")); + assert!(!community_production.contains(".fetch_one(&self.pool)")); + assert!(!community_production.contains(".fetch_all(&self.pool)")); + assert!(!community_production.contains(".execute(&self.pool)")); + assert_eq!( + community_production + .matches(".fetch_optional(&self.pool)") + .count(), + 1, + "only the out-of-scope NIP-11 metadata read may retain a raw pool checkout" + ); + + let thread_summary = thread + .split_once("pub async fn get_thread_summary(") + .expect("thread store must expose get_thread_summary") + .1 + .split_once("/// Fetch one channel window") + .expect("thread summary must precede channel-window reads") + .0; + assert!(thread_summary.contains("WriterOperation::EventWrite")); + assert!(thread_summary.contains("fetch_optional(&mut *connection)")); + assert!(thread_summary.contains("fetch_all(&mut *connection)")); + assert!(!thread_summary.contains("fetch_optional(pool)")); + assert!(!thread_summary.contains("fetch_all(pool)")); + + let archived_identities = include_str!("../src/store/archived_identities.rs"); + let archived_identity_production = archived_identities + .split("\n#[cfg(test)]") + .next() + .expect("archived identity production source"); + assert_eq!( + archived_identity_production + .matches("WriterOperation::EventWrite") + .count(), + 4, + "all four archived identity operations must be attributed to event writes" + ); + assert!(!archived_identity_production.contains("fetch_optional(pool)")); + assert!(!archived_identity_production.contains("fetch_all(pool)")); + assert!(!archived_identity_production.contains("execute(pool)")); + + let relay_main = include_str!("../../buzz-relay/src/main.rs"); + assert!(relay_main.contains("pool_state.db.refresh_pool_waiter_metrics();")); + assert!(relay_main.contains(".ensure_configured_community_for_bootstrap(")); + + let runtime = include_str!("../src/runtime/mod.rs"); + assert!(runtime.contains("observability::refresh_pool_waiters(self.read_pool.is_some())")); + assert!(runtime.contains("self.verify_replica_fence_at_boot().await?")); + let fence_boot = runtime + .split_once("pub(crate) async fn verify_replica_fence_at_boot") + .expect("runtime must expose attributed boot fence verification") + .1 + .split_once("/// The pool for lag-tolerant reads") + .expect("boot fence verification must precede routed-read plumbing") + .0; + assert!(fence_boot.contains("WriterOperation::Bootstrap")); + + let replica_fence = include_str!("../src/runtime/replica_fence.rs"); + let replica_fence_production = replica_fence + .split("\n#[cfg(test)]") + .next() + .expect("replica-fence production source"); + assert!(replica_fence_production.contains("WriterOperation::Bootstrap")); + assert!(replica_fence_production.contains("WriterOperation::Maintenance")); + assert!(!replica_fence_production.contains("pool.begin().await")); + assert!(!replica_fence_production.contains("writer.acquire().await")); + assert!(!replica_fence_production.contains("fetch_optional(writer)")); + + let usage = include_str!("../src/store/usage.rs"); + let usage_production = usage + .split("\n#[cfg(test)]") + .next() + .expect("usage production source"); + let usage_leader_lock = usage_production + .split_once("pub async fn try_lock_usage_metrics(") + .expect("usage store must expose the legacy leader-lock acquisition") + .1 + .split_once("pub async fn usage_community_count(") + .expect("usage leader lock must precede counter reads") + .0; + assert!(usage_leader_lock.contains("acquire_writer_with_legacy_metrics(")); + assert!( + usage_production + .matches("WriterOperation::Maintenance") + .count() + >= 11, + "every periodic usage checkout must be maintenance-attributed" + ); + for bypass in [ + ".fetch_one(pool)", + ".fetch_all(pool)", + ".fetch_optional(pool)", + ".execute(pool)", + ] { + assert!( + !usage_production.contains(bypass), + "usage production path bypasses operation attribution with {bypass}" + ); + } + + let channel_reaper = channel + .split_once("pub async fn reap_expired_ephemeral_channels(pool:") + .expect("channel store must expose ephemeral reaper") + .1 + .split_once("\nimpl Db {") + .expect("ephemeral reaper must precede Db wrappers") + .0; + assert!(channel_reaper.contains("WriterOperation::Maintenance")); + assert!(channel_reaper.contains("fetch_all(&mut *connection)")); + + let deletion = include_str!("../src/store/deletion.rs"); + let lease_reaper = deletion + .split_once("pub async fn reap_expired_serving_write_leases") + .expect("deletion store must expose serving-lease reaper") + .1 + .split_once("/// Return serving-lease counts") + .expect("serving-lease reaper must precede stats") + .0; + assert!(lease_reaper.contains("WriterOperation::Maintenance")); + assert!(lease_reaper.contains("execute(&mut *connection)")); + let lease_stats = deletion + .split_once("pub async fn serving_lease_stats") + .expect("deletion store must expose serving-lease stats") + .1 + .split_once("/// Whether a community remains active") + .expect("serving-lease stats must precede serving-state reads") + .0; + assert!(lease_stats.contains("WriterOperation::Maintenance")); + assert!(lease_stats.contains("fetch_one(&mut *connection)")); + for (start, end) in [ + ( + "pub async fn acquire_serving_write_lease", + "/// Renew an already-admitted external side-effect lease", + ), + ( + "pub async fn renew_serving_write_lease", + "/// Release a serving side-effect lease", + ), + ( + "pub async fn release_serving_write_lease", + "/// Check that an external side-effect lease remains current", + ), + ( + "pub async fn verify_serving_write_lease", + "/// Delete expired serving leases", + ), + ( + "pub async fn is_serving_active", + "async fn advance_with_checkpoint", + ), + ] { + let function = deletion + .split_once(start) + .unwrap_or_else(|| panic!("missing serving-write seam {start}")) + .1 + .split_once(end) + .unwrap_or_else(|| panic!("serving-write seam {start} must precede {end}")) + .0; + assert!( + function.contains("WriterOperation::EventWrite"), + "serving-write seam {start} must be event-write attributed" + ); + assert!(!function.contains("self.pool.begin().await")); + assert!(!function.contains(".execute(&self.pool)")); + assert!(!function.contains(".fetch_one(&self.pool)")); + } + + let ensure_authorization = user + .split_once("pub async fn ensure_user_for_authorization(") + .expect("user store must expose NIP-OA authorization ensure") + .1 + .split_once("/// Get a single user record") + .expect("authorization ensure must precede generic user reads") + .0; + assert!(ensure_authorization.contains("WriterOperation::Authorization")); + let set_owner_authorization = user + .split_once("pub async fn set_agent_owner_for_authorization(") + .expect("user store must expose NIP-OA authorization owner write") + .1 + .split_once("/// Get the channel_add_policy") + .expect("authorization owner write must precede policy reads") + .0; + assert!(set_owner_authorization.contains("WriterOperation::Authorization")); + let relay_api = include_str!("../../buzz-relay/src/api/mod.rs"); + assert!(relay_api.contains(".ensure_user_for_authorization(")); + assert!(relay_api.contains(".set_agent_owner_for_authorization(")); + + for (domain, source) in [ + ( + "channel_members", + include_str!("../src/store/channel_members.rs"), + ), + ("archived_identities", archived_identities), + ("event", event), + ("git_repo", include_str!("../src/store/git_repo.rs")), + ("push", include_str!("../src/store/push.rs")), + ("replica_fence", replica_fence), + ("reaction", include_str!("../src/store/reaction.rs")), + ("relay_invite", include_str!("../src/store/relay_invite.rs")), + ( + "relay_members", + include_str!("../src/store/relay_members.rs"), + ), + ("thread", thread), + ( + "relay_operators", + include_str!("../src/store/relay_operators.rs"), + ), + ("usage", usage), + ] { + let production = source.split("\n#[cfg(test)]").next().unwrap_or(source); + for bypass in [ + "pool.begin().await", + "self.pool.begin().await", + ".fetch_one(pool)", + ".fetch_one(&self.pool)", + ".fetch_all(pool)", + ".fetch_all(&self.pool)", + ".fetch_optional(pool)", + ".fetch_optional(&self.pool)", + ".execute(pool)", + ".execute(&self.pool)", + ] { + assert!( + !production.contains(bypass), + "{domain} production path bypasses operation attribution with {bypass}" + ); + } + } +} diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index f13b7d507ac..714cf7eaff2 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -531,11 +531,14 @@ fn resolve_submit_host(host: Option<&str>, relay_url: Option<&str>) -> Result Result { let database_url = required_env("DATABASE_URL")?; - let db = Db::new(&DbConfig { - database_url, - max_connections: env_parse("BUZZ_DB_POOL_SIZE", 20), - ..DbConfig::default() - }) + let db = Db::new( + &DbConfig { + database_url, + max_connections: env_parse("BUZZ_DB_POOL_SIZE", 20), + ..DbConfig::default() + } + .with_session_timeouts_from_env(), + ) .await?; Ok(store(&db)) } @@ -1599,7 +1602,7 @@ fn print_json(value: &impl Serialize) -> Result<()> { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[test] @@ -1657,7 +1660,9 @@ mod tests { .await .expect("connect deletion engine test DB"); let db = Db::from_pool(pool); - db.migrate().await.expect("migrate deletion engine test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate deletion engine test DB"); + } let store = db.deletion_store(); let host = format!("{prefix}-{}.example", Uuid::new_v4().simple()); let community = db @@ -1814,8 +1819,6 @@ mod tests { ) } - #[tokio::test] - #[ignore = "requires Postgres"] async fn approved_stage_allows_post_inventory_row_churn_before_fencing() { let (db, services, claim) = claimed_test_deletion("deletion-row-churn").await; let frozen: FrozenInventory = serde_json::from_value( @@ -1877,8 +1880,6 @@ mod tests { /// then the worker died before the chunk stamp. Resume must re-delete the /// chunk (missing keys report as deleted — idempotent), stamp it, and /// finish the stage. - #[tokio::test] - #[ignore = "requires Postgres and S3-compatible storage"] async fn drained_stage_resumes_chunk_deleted_before_stamp() { let (_, mut services, claim) = claimed_test_deletion("deletion-chunk-resume").await; services.media = deletion_test_media_storage(); @@ -2133,8 +2134,6 @@ mod tests { assert!(scan_proves_absence(&[(9, Vec::new()), (0, Vec::new())])); } - #[tokio::test] - #[ignore = "requires Postgres and S3-compatible storage"] async fn final_storage_verification_rejects_late_target_binding() { let (_, mut services, claim) = claimed_test_deletion("deletion-late-binding").await; services.media = deletion_test_media_storage(); @@ -2159,6 +2158,26 @@ mod tests { .expect("empty tenant prefixes verify clean"); } + mod external_infra_s3_tests { + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn approved_stage_allows_post_inventory_row_churn_before_fencing() { + super::approved_stage_allows_post_inventory_row_churn_before_fencing().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn drained_stage_resumes_chunk_deleted_before_stamp() { + super::drained_stage_resumes_chunk_deleted_before_stamp().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn final_storage_verification_rejects_late_target_binding() { + super::final_storage_verification_rejects_late_target_binding().await; + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn stale_lease_during_failure_recording_is_lost_ownership() { @@ -2256,7 +2275,9 @@ mod tests { .await .expect("connect serving guard test DB"); let db = Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate serving guard test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate serving guard test DB"); + } let community = db .ensure_configured_community(&format!( "serving-guard-{}.example", diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index 87c3a119317..d555b6ea542 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -39,7 +39,7 @@ impl DevMcp { #[tool( name = "shell", - description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 600000 (10 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files), tree (flags: -d ; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)." + description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 1,200,000 (20 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files), tree (flags: -d ; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)." )] async fn shell( &self, diff --git a/crates/buzz-dev-mcp/src/shell.rs b/crates/buzz-dev-mcp/src/shell.rs index 7aa95b1d879..140d3c44cc9 100644 --- a/crates/buzz-dev-mcp/src/shell.rs +++ b/crates/buzz-dev-mcp/src/shell.rs @@ -14,7 +14,7 @@ use tokio::process::Command; use tokio_util::sync::CancellationToken; const DEFAULT_TIMEOUT_MS: u64 = 120_000; -const MAX_TIMEOUT_MS: u64 = 600_000; +const MAX_TIMEOUT_MS: u64 = 1_200_000; const MAX_COMMAND_BYTES: usize = 1_000_000; const CAPTURE_CAP: usize = 10 * 1024 * 1024; const MAX_BYTES: usize = 50 * 1024; @@ -121,12 +121,16 @@ pub struct ShellParams { pub command: String, #[serde(default)] pub workdir: Option, - /// Defaults to 120000 ms (2 min) if omitted; capped at 600000 ms (10 min). + /// Defaults to 120000 ms (2 min) if omitted; capped at 1,200,000 ms (20 min). /// For long-running commands (git push with hooks, cargo build, test suites), use 300000+. #[serde(default)] pub timeout_ms: Option, } +fn effective_timeout_ms(requested: Option) -> u64 { + requested.unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS) +} + pub async fn run( state: &SharedState, p: ShellParams, @@ -138,10 +142,7 @@ pub async fn run( None, )); } - let timeout_ms = p - .timeout_ms - .unwrap_or(DEFAULT_TIMEOUT_MS) - .min(MAX_TIMEOUT_MS); + let timeout_ms = effective_timeout_ms(p.timeout_ms); let workdir: PathBuf = p .workdir .as_deref() @@ -1002,6 +1003,15 @@ mod tests { serde_json::from_str(&text).expect("json") } + #[test] + fn timeout_bounds_preserve_default_and_cap_requests_at_twenty_minutes() { + assert_eq!(effective_timeout_ms(None), 120_000); + assert_eq!(effective_timeout_ms(Some(120_000)), 120_000); + assert_eq!(effective_timeout_ms(Some(1_200_000)), 1_200_000); + assert_eq!(effective_timeout_ms(Some(1_200_001)), 1_200_000); + assert_eq!(effective_timeout_ms(Some(u64::MAX)), 1_200_000); + } + #[tokio::test(flavor = "current_thread")] async fn basic_echo() { let dir = tempdir().expect("tempdir"); diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 6cbfb45893d..17fff2a2429 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -510,15 +510,15 @@ impl AuthorityStore for PostgresAuthorityStore { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- fixed localhost-only test credential #[tokio::test] #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] - async fn readiness_requires_migrated_schema_dml_and_no_ddl() { + async fn cluster_global_readiness_requires_migrated_schema_dml_and_no_ddl() { let admin_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_owned()); diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 2f0d128fc87..19f2153b95b 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1254,7 +1254,7 @@ fn summarize_body(body: &str, tags: &serde_json::Value) -> String { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use auth::ADMIN_API_PREFIX; use axum::{ @@ -1265,6 +1265,12 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; + fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| { + "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string() // sadscan:disable np.postgres.1 -- local test-only credentials + }) + } + /// Deterministic operator keypair for the default authorized test state. /// Rostered as a config operator in `test_state()` so `authorized()` can /// mint NIP-98 credentials that resolve to an Operator principal without a @@ -1992,6 +1998,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires PostgreSQL"] async fn nip98_mode_unrostered_signer_does_not_consume_a_replay_slot() { // Regression: the replay ID must be claimed only AFTER principal // resolution succeeds. A validly-signing but unrostered key (any @@ -2366,12 +2373,9 @@ mod tests { auth: crate::config::AdminAuth::Nip98, web_dir: None, }); - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) .create_pool(Some(deadpool_redis::Runtime::Tokio1)) @@ -3270,12 +3274,9 @@ mod tests { // At the DB level: claim_report with two concurrent UUIDs on the same report_id. // FOR UPDATE row lock ensures serial execution; first commit wins, second // returns NotOpen. moderation_actions must have exactly 1 row. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3372,12 +3373,9 @@ mod tests { async fn same_request_id_retry_returns_existing_action() { // Two POST /reports/{id}/resolve calls with the same requestId UUID. // Both should return 200 with the same actionId. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3478,12 +3476,9 @@ mod tests { // // resolve_report_decision_atomic CASes on status='open'; if the report is // already 'processing', the transaction rolls back with no audit row. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3578,12 +3573,9 @@ mod tests { // After an enforcement action reaches mutation_committed step_marker, // attempting to cancel the action record must fail (cancel is only // legal pre-mutation). - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3776,12 +3768,9 @@ mod tests { async fn reports_default_lists_escalated_only() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3803,12 +3792,9 @@ mod tests { async fn reports_scope_all_lists_every_status() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3827,12 +3813,9 @@ mod tests { async fn reports_explicit_status_filter_overrides_default() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3852,12 +3835,9 @@ mod tests { async fn reopen_route_returns_report_to_open_and_writes_audit_row() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "resolved").await; let request_id = Uuid::new_v4(); @@ -3917,12 +3897,9 @@ mod tests { let operator_keys = nostr::Keys::generate(); let operator_bytes = operator_keys.public_key().to_bytes(); let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; // Unique per-invocation correlation: `reason` flows to the audit row's @@ -4014,12 +3991,9 @@ mod tests { // Only the operator is config-backed (Operator role); the target is a // fresh, mutable, non-config key. let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let target_keys = nostr::Keys::generate(); let target_hex = target_keys.public_key().to_hex(); @@ -4118,12 +4092,9 @@ mod tests { async fn resolve_route_rejects_adversarial_expiration_and_leaves_report_open() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); // 0, over-cap, i64::MAX magnitude, and a value that casts to a negative // i64 (wrapped-past-expiry) — all must reject before any state change. @@ -4187,12 +4158,9 @@ mod tests { async fn mixed_case_non_config_staffing_normalizes_to_one_row() { let operator_keys = nostr::Keys::generate(); let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let target_keys = nostr::Keys::generate(); let lower_hex = target_keys.public_key().to_hex(); @@ -4280,12 +4248,9 @@ mod tests { async fn reopen_route_rejects_non_terminal_report_with_409() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; let body = serde_json::json!({ "requestId": Uuid::new_v4() }).to_string(); @@ -4313,12 +4278,9 @@ mod tests { async fn cancel_route_returns_open_and_embeds_the_cancelled_action_dto() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; let community_id: Uuid = sqlx::query_scalar("SELECT community_id FROM moderation_reports WHERE id = $1") @@ -4435,12 +4397,9 @@ mod tests { // community fence — can block this: it is the sharper negative case. let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); // Two reports on the same admin.example community, each driven to // `processing` with its own distinct pre-mutation `failed` action. @@ -4582,12 +4541,9 @@ mod tests { // Simulate a crash after mutation_committed but before finalization. // Re-drive from persisted step state must produce exactly one // enforcement, one report transition, one audit chain, one reporter notice. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -4797,8 +4753,7 @@ mod tests { } async fn e2e_pool() -> sqlx::PgPool { - let url = std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let url = database_url(); sqlx::PgPool::connect(&url) .await .expect("connect to test DB") @@ -7345,8 +7300,7 @@ mod tests { // Our outbox row's created_at is ~10 s ago → trigger fires on insert_event. // This pool is fully isolated: no other pool or test is affected, and there // is no cleanup dependence (dropping the pool closes all its connections). - let db_url = std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let db_url = database_url(); let floor_pool = sqlx::postgres::PgPoolOptions::new() .max_connections(4) .after_connect(|conn, _meta| { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5dbb2aaf50c..37c549610de 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -55,17 +55,28 @@ pub(crate) async fn enforce_http_admission( } } +/// Values retained from an already-verified bridge authentication event. +#[derive(Debug)] +pub(crate) struct VerifiedBridgeAuth { + pub(crate) pubkey: nostr::PublicKey, + pub(crate) event_id_bytes: [u8; 32], + pub(crate) signed_created_at: Option, +} + +type BridgeAuthResult = Result)>; + /// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode). /// -/// Returns the authenticated public key and an event ID for replay detection. -/// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). +/// Returns the authenticated public key, an event ID for replay detection, and +/// the verified signed auth timestamp. For X-Pubkey dev mode, the event ID is +/// a zero hash and the timestamp is absent. pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, body: Option<&[u8]>, require_auth_token: bool, -) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { +) -> BridgeAuthResult { verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false) } @@ -76,7 +87,7 @@ pub(crate) fn verify_bridge_auth_with_options( body: Option<&[u8]>, require_auth_token: bool, require_payload: bool, -) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { +) -> BridgeAuthResult { // Try NIP-98 first (Authorization: Nostr ) if let Some(auth_str) = headers .get("authorization") @@ -111,7 +122,11 @@ pub(crate) fn verify_bridge_auth_with_options( let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body) .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; - return Ok((pubkey, event_id_bytes)); + return Ok(VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at: Some(event.created_at.as_secs()), + }); } // Dev-mode fallback: X-Pubkey header (only when require_auth_token is false) @@ -120,7 +135,11 @@ pub(crate) fn verify_bridge_auth_with_options( let pubkey = nostr::PublicKey::from_hex(hex_val) .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?; // Zero event ID — no replay detection needed for dev mode - return Ok((pubkey, [0u8; 32])); + return Ok(VerifiedBridgeAuth { + pubkey, + event_id_bytes: [0u8; 32], + signed_created_at: None, + }); } } @@ -723,7 +742,11 @@ pub async fn submit_event( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -736,8 +759,16 @@ pub async fn submit_event( // runs inside the helper. The thin wrapper here owns the single terminal // attribution line so it fires for every outcome, including admission/ // replay/membership failures that previously returned before any log fired. - let outcome = - submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let outcome = submit_event_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &outcome { SubmitOutcome::Ok { accepted, kind, .. } => { @@ -846,6 +877,7 @@ async fn submit_event_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> SubmitOutcome { // Admission and replay checks fire before body parse — a 429 or replay // reject on a malformed body must still be attributed. @@ -888,18 +920,23 @@ async fn submit_event_authed( }; // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); let nip_oa_owner = match super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await { Ok(owner) => owner.or_else(|| { if !state.config.require_relay_membership { - super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag) + super::relay_members::extract_nip_oa_owner( + &pubkey_bytes, + auth_tag, + signed_auth_created_at, + ) } else { None } @@ -994,7 +1031,11 @@ pub async fn query_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -1007,8 +1048,16 @@ pub async fn query_events( // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - query_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = query_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &result { Ok(Json(Value::Array(events))) => { tracing::info!( @@ -1044,17 +1093,19 @@ async fn query_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await?; @@ -1523,7 +1574,11 @@ pub async fn count_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -1536,8 +1591,16 @@ pub async fn count_events( // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - count_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = count_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &result { Ok(Json(value)) => { let count = value.get("count").and_then(Value::as_u64); @@ -1571,17 +1634,19 @@ async fn count_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await?; @@ -2309,8 +2374,11 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -2462,7 +2530,7 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; use std::sync::Mutex; @@ -2697,8 +2765,6 @@ mod tests { /// replay of the same event id in the same community is rejected. The same /// id in a different community still succeeds, proving the key is scoped by /// server-resolved tenant rather than global process memory. - #[tokio::test] - #[ignore = "requires Redis"] async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() { let pool = redis_pool(); let pod_a = buzz_pubsub::RedisNip98ReplayGuard::new(pool.clone()); @@ -2726,8 +2792,6 @@ mod tests { /// rejection. A single guard instance, called twice with the same /// `TenantContext` and the same event id, MUST reject the second call. /// Bites if `try_mark`'s admit/reject mapping is reversed or no-op'd. - #[tokio::test] - #[ignore = "requires Redis"] async fn nip98_replay_guard_rejects_same_pod_same_community_replay() { let pool = redis_pool(); let pod = buzz_pubsub::RedisNip98ReplayGuard::new(pool); @@ -2744,6 +2808,20 @@ mod tests { assert_eq!(status, StatusCode::UNAUTHORIZED); } + mod external_infra_redis_tests { + #[tokio::test] + #[ignore = "requires Redis"] + async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() { + super::nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path().await; + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn nip98_replay_guard_rejects_same_pod_same_community_replay() { + super::nip98_replay_guard_rejects_same_pod_same_community_replay().await; + } + } + /// Attack 3 fail-closed guard: a stateless worker that loses Redis MUST /// reject the request, never admit it. The shared seen-set is the /// freshness fence; degrading to "best effort, allow on error" forfeits @@ -2921,14 +2999,21 @@ mod tests { let tenant_a = fresh_tenant("host-a.example"); let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events"); - let (pubkey, _event_id_bytes) = - verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) - .expect("matching-host NIP-98 event must verify"); + let VerifiedBridgeAuth { + pubkey, + signed_created_at, + .. + } = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) + .expect("matching-host NIP-98 event must verify"); assert_eq!( pubkey, keys.public_key(), "returned pubkey must be the signer's" ); + assert!( + signed_created_at.is_some(), + "verified NIP-98 auth must retain its signed timestamp" + ); } /// Mirror of the query-reconstruction `authorize_moderation_read` performs @@ -2970,7 +3055,7 @@ mod tests { Some("limit=20&status=open"), ); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("query-bearing moderation read must verify against the same query"); assert_eq!(pubkey, keys.public_key()); @@ -3027,7 +3112,7 @@ mod tests { Some("limit=20"), ); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("audit query-bearing read must verify"); assert_eq!(pubkey, keys.public_key()); @@ -3052,7 +3137,7 @@ mod tests { ); assert_eq!(expected_url, "https://host-a.example/moderation/restricted"); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("query-less restricted read must verify against the bare path"); assert_eq!(pubkey, keys.public_key()); @@ -3744,8 +3829,6 @@ mod tests { } } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - /// Build an AppState suitable for handler-level bridge tests. /// /// - `require_auth_token = false` → X-Pubkey dev-mode fallback active. @@ -3758,7 +3841,7 @@ mod tests { /// Returns `None` when local Postgres is not reachable. async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. config.redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); @@ -3766,7 +3849,9 @@ mod tests { config.require_auth_token = false; config.require_relay_membership = false; - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) .create_pool(Some(deadpool_redis::Runtime::Tokio1)) diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index a8848af295a..c29df6746bb 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -138,7 +138,11 @@ async fn authenticate( })?; let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = bridge::verify_bridge_auth_with_options( headers, "POST", &expected_url, @@ -152,9 +156,8 @@ async fn authenticate( state, tenant.community(), &pubkey.to_bytes(), - headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()), + relay_members::extract_auth_tag_header(headers), + signed_created_at, ) .await?; diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index 32d63f46008..40d4eea0352 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -462,7 +462,7 @@ pub fn generate_hook_hmac( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; fn make_request() -> HookCallbackRequest { diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index ec7af3aac65..638e3c7156b 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -200,22 +200,21 @@ impl axum::extract::FromRequestParts> for GitAuth { let event: nostr::Event = serde_json::from_str(&event_json) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid auth event").into_response())?; + let signed_auth_created_at = event.created_at.as_secs(); // Relay membership gate (NIP-43). Git cannot carry a standalone // x-auth-tag header through the credential-helper protocol, so agents // attach their NIP-OA attestation to the signed NIP-98 event, matching // the WebSocket NIP-42 flow. let event_auth_tag = crate::handlers::auth::extract_auth_tag_json(&event); - let header_auth_tag = parts - .headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); + let header_auth_tag = crate::api::relay_members::extract_auth_tag_header(&parts.headers); let auth_tag = event_auth_tag.as_deref().or(header_auth_tag); if crate::api::relay_members::enforce_relay_membership( state, tenant.community(), pubkey.as_bytes(), auth_tag, + Some(signed_auth_created_at), ) .await .is_err() @@ -224,7 +223,14 @@ 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?; + deny_banned_git_principal( + &state.db, + tenant.community(), + &pubkey, + auth_tag, + Some(signed_auth_created_at), + ) + .await?; Ok(GitAuth { pubkey, tenant }) } @@ -246,6 +252,7 @@ async fn deny_banned_git_principal( community: buzz_core::CommunityId, pubkey: &nostr::PublicKey, auth_tag: Option<&str>, + signed_auth_created_at: Option, ) -> Result<(), Response> { let agent = git_restriction_state(db, community, pubkey).await?; @@ -254,7 +261,11 @@ async fn deny_banned_git_principal( let owner = if agent.banned { None } else { - crate::api::relay_members::extract_nip_oa_owner(pubkey.as_bytes(), auth_tag) + crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag, + signed_auth_created_at, + ) }; let owner_state = match owner { Some(owner) => Some(git_restriction_state(db, community, &owner).await?), @@ -2424,8 +2435,6 @@ mod track_c_tests { } } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { let (state, pool) = finalize_test_state().await; let host = format!( @@ -2522,8 +2531,6 @@ mod track_c_tests { pool.close().await; } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn finalize_push_holds_serving_lease_through_post_cas_publication() { let (state, pool) = finalize_test_state().await; let host = format!("git-finalize-{}.example", uuid::Uuid::new_v4().simple()); @@ -2615,8 +2622,6 @@ mod track_c_tests { pool.close().await; } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() { let (state, pool) = finalize_test_state().await; let host = format!( @@ -2657,6 +2662,26 @@ mod track_c_tests { pool.close().await; } + mod external_infra_minio_tests { + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { + super::repo_announcement_holds_serving_lease_until_pointer_is_seeded().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_holds_serving_lease_through_post_cas_publication() { + super::finalize_push_holds_serving_lease_through_post_cas_publication().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() { + super::finalize_push_db_failure_after_cas_is_not_success_and_releases_lease().await; + } + } + /// 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 @@ -3169,7 +3194,7 @@ mod track_c_tests { } #[cfg(test)] -mod sec005_read_gate_tests { +mod sec005_postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -3652,7 +3677,7 @@ mod sec005_read_gate_tests { db.ensure_user(community, &member_pk).await.expect("member"); assert!( - deny_banned_git_principal(&db, community, &member.public_key(), None) + deny_banned_git_principal(&db, community, &member.public_key(), None, None) .await .is_ok(), "precondition: an unbanned member passes the git ban gate" @@ -3663,7 +3688,7 @@ mod sec005_read_gate_tests { .expect("ban"); let (status, body) = denial_parts( - deny_banned_git_principal(&db, community, &member.public_key(), None).await, + deny_banned_git_principal(&db, community, &member.public_key(), None, None).await, ) .await; assert_eq!(status, StatusCode::FORBIDDEN); @@ -3686,9 +3711,15 @@ mod sec005_read_gate_tests { .expect("auth tag"); assert!( - deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)) - .await - .is_ok(), + deny_banned_git_principal( + &db, + community, + &agent.public_key(), + Some(&auth_tag), + Some(200), + ) + .await + .is_ok(), "precondition: neither agent nor owner is banned" ); @@ -3698,7 +3729,14 @@ mod sec005_read_gate_tests { .expect("ban owner"); let (status, _) = denial_parts( - deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)).await, + deny_banned_git_principal( + &db, + community, + &agent.public_key(), + Some(&auth_tag), + Some(200), + ) + .await, ) .await; assert_eq!( @@ -3710,7 +3748,7 @@ mod sec005_read_gate_tests { // 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) + deny_banned_git_principal(&db, community, &agent.public_key(), None, None) .await .is_ok(), "without an attestation there is no owner to inherit from" @@ -3732,7 +3770,8 @@ mod sec005_read_gate_tests { 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, + deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None, None) + .await, ) .await; assert_eq!( diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d09c7fc6119..6714281f40f 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -247,7 +247,11 @@ async fn authenticate( })?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_bridge_auth_with_options( headers, "POST", &url, @@ -537,7 +541,7 @@ fn claim_key_rate_limited( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Arc; use std::time::Duration; diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 7a9f8816fed..780532ec5d0 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -208,12 +208,13 @@ impl FromRequestParts> for AuthenticatedUpload { // storage and of `require_auth_token` (which governs the REST API, not // media). On open relays (membership disabled) any valid Blossom signer // may upload, matching the WS door's admission policy. - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, + Some(auth_event.created_at.as_secs()), ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; @@ -534,12 +535,13 @@ async fn authenticate_media_read( let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, + Some(auth_event.created_at.as_secs()), ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 204ec360c3f..5745b8d4e59 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -37,7 +37,10 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { /// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`, /// `git/transport.rs`, and `audio/handler.rs`. pub mod relay_members { - use axum::{http::StatusCode, response::Json}; + use axum::{ + http::{HeaderMap, StatusCode}, + response::Json, + }; use buzz_core::{tenant::CommunityId, TenantContext}; use tracing::{debug, info}; @@ -56,15 +59,30 @@ pub mod relay_members { Denied, } + /// Return the sole NIP-OA credential header, if one was supplied. + /// + /// Repeated security-sensitive headers are ambiguous across HTTP stacks, + /// so they are treated as no credential instead of silently selecting one. + pub fn extract_auth_tag_header(headers: &HeaderMap) -> Option<&str> { + let mut values = headers.get_all("x-auth-tag").iter(); + let (Some(value), None) = (values.next(), values.next()) else { + return None; + }; + value.to_str().ok() + } + /// Check relay membership without committing to an HTTP response shape. /// /// `community` is the server-resolved tenant of the request; membership is /// scoped to it so admitting a pubkey to community A never admits it to B. + /// A NIP-OA credential is usable only when `signed_auth_created_at` came + /// from the already-verified authentication event carrying that request. pub async fn check_relay_membership( state: &AppState, community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Result { if !state.config.require_relay_membership { return Ok(MembershipDecision::OpenRelay); @@ -84,8 +102,16 @@ pub mod relay_members { if let Some(tag_json) = auth_tag_header { let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes) .map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?; + let Some(auth_created_at) = signed_auth_created_at else { + info!(agent = %pubkey_hex, "NIP-OA auth tag has no verified signed auth timestamp"); + return Ok(MembershipDecision::Denied); + }; - match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event( + tag_json, + &agent_pubkey, + auth_created_at, + ) { Ok(owner_pubkey) => { let owner_hex = owner_pubkey.to_hex(); let owner_is_member = state @@ -128,8 +154,17 @@ pub mod relay_members { community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { - match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await { + match check_relay_membership( + state, + community, + pubkey_bytes, + auth_tag_header, + signed_auth_created_at, + ) + .await + { Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), Ok(MembershipDecision::Denied) => Err(( @@ -150,16 +185,22 @@ pub mod relay_members { /// /// Used on open relays (`require_relay_membership = false`) to opportunistically /// extract the owner pubkey for agent→owner backfill. The NIP-OA signature is - /// cryptographically self-proving, so no feature flag is needed — if the tag - /// verifies, the owner relationship is authentic. Returns `None` if the tag - /// is absent or invalid. + /// cryptographically self-proving, so no feature flag is needed. Temporal + /// conditions are evaluated against `signed_auth_created_at`. Returns + /// `None` if the tag, timestamp, or conditions are absent or invalid. pub fn extract_nip_oa_owner( pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Option { let tag_json = auth_tag_header?; + let auth_created_at = signed_auth_created_at?; let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?; - match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event( + tag_json, + &agent_pubkey, + auth_created_at, + ) { Ok(owner) => Some(owner), Err(e) => { info!("extract_nip_oa_owner: invalid auth tag: {e}"); @@ -182,7 +223,7 @@ pub mod relay_members { for (role, pubkey) in [("agent", agent), ("owner", owner)] { match state .db - .ensure_user(tenant.community(), pubkey.as_bytes()) + .ensure_user_for_authorization(tenant.community(), pubkey.as_bytes()) .await { Ok(true) => { @@ -202,7 +243,11 @@ pub mod relay_members { let materialized = match state .db - .set_agent_owner(tenant.community(), agent.as_bytes(), owner.as_bytes()) + .set_agent_owner_for_authorization( + tenant.community(), + agent.as_bytes(), + owner.as_bytes(), + ) .await { Ok(true) => true, @@ -236,9 +281,22 @@ pub mod relay_members { #[cfg(test)] mod tests { use super::*; + use axum::http::{HeaderMap, HeaderValue}; use buzz_sdk::nip_oa::compute_auth_tag; use nostr::Keys; + #[test] + fn auth_tag_header_must_be_unique() { + let mut headers = HeaderMap::new(); + assert_eq!(extract_auth_tag_header(&headers), None); + + headers.insert("x-auth-tag", HeaderValue::from_static("credential-one")); + assert_eq!(extract_auth_tag_header(&headers), Some("credential-one")); + + headers.append("x-auth-tag", HeaderValue::from_static("credential-two")); + assert_eq!(extract_auth_tag_header(&headers), None); + } + /// Valid NIP-OA auth tag → returns Some(owner_pubkey). #[test] fn valid_nip_oa_returns_owner() { @@ -249,18 +307,62 @@ pub mod relay_members { let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") .expect("compute_auth_tag must succeed"); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&tag_json)); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + Some(&tag_json), + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, Some(owner_keys.public_key())); } + #[test] + fn nip_oa_time_conditions_use_signed_auth_event_time() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + + let expired = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<200") + .expect("sign expired credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&expired), Some(200)), + None + ); + + let future = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at>200") + .expect("sign future credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&future), Some(200)), + None + ); + + let in_window = compute_auth_tag( + &owner_keys, + &agent_pubkey, + "kind=9&created_at>199&created_at<201", + ) + .expect("sign in-window credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), Some(200)), + Some(owner_keys.public_key()) + ); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), None), + None, + "a credential without a verified signed auth timestamp must fail closed" + ); + } + /// No auth tag → returns None. #[test] fn no_auth_tag_returns_none() { let agent_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), None); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + None, + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, None); } @@ -271,7 +373,11 @@ pub mod relay_members { let agent_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some("not valid json")); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + Some("not valid json"), + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, None); } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index f19ac17d4c1..2c49ca6a5c3 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -75,7 +75,11 @@ async fn authorize_operator_request( _ => path.to_string(), }; let url = format!("{origin}{path_with_query}"); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_bridge_auth_with_options( headers, method, &url, @@ -498,7 +502,7 @@ pub async fn community_availability( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Arc; use axum::{ @@ -532,8 +536,6 @@ mod tests { Box::pin(async { Ok(true) }) } } - - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { @@ -571,7 +573,7 @@ mod tests { async fn operator_test_state(operator_keys: &[Keys]) -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + config.database_url = crate::test_support::database_url(); config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_url = "wss://tenant.example".to_string(); config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}")); @@ -581,7 +583,9 @@ mod tests { .collect(); config.require_relay_membership = true; - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index a3d5a6c729e..c7fa09bebd0 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -62,20 +62,22 @@ async fn authorize_workflow_read( let path_with_query = request_path(path, raw_query); let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; bridge::enforce_http_admission(state, &tenant, &pubkey).await?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_created_at, ) .await?; diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index de8f1e14591..6e6d467d092 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -220,6 +220,7 @@ async fn handle_active_audio_connection( // Extract NIP-OA auth tag before verify_auth_event consumes the event. let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); + let signed_auth_created_at = auth_msg.event.created_at.as_secs(); let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); let auth_ctx = match state @@ -251,6 +252,7 @@ async fn handle_active_audio_connection( tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) .await .is_err() @@ -381,6 +383,11 @@ async fn handle_active_audio_connection( } } + let lifecycle_generation = pending_remote + .as_ref() + .map(|outcome| outcome.generation().to_string()) + .unwrap_or_else(|| state.huddle_liveness_generation.to_string()); + let room = state .audio_rooms .get_or_create(tenant.community(), channel_id); @@ -707,6 +714,7 @@ async fn handle_active_audio_connection( participant_pubkey: &pubkey_hex, roster_revision: Some(lifecycle_revision), admission_id: Some(peer_id), + generation: &lifecycle_generation, }, ) .await; @@ -912,6 +920,7 @@ async fn handle_active_audio_connection( participant_pubkey: &pubkey_hex, roster_revision: removal_revision, admission_id: Some(peer_id), + generation: &lifecycle_generation, }, ) .await; @@ -945,6 +954,7 @@ async fn handle_active_audio_connection( participant_pubkey: &pubkey_hex, roster_revision: None, admission_id: None, + generation: &lifecycle_generation, }, ) .await; @@ -1347,6 +1357,7 @@ struct ParticipantLifecycle<'a> { participant_pubkey: &'a str, roster_revision: Option, admission_id: Option, + generation: &'a str, } async fn emit_participant_event( @@ -1361,22 +1372,29 @@ async fn emit_participant_event( participant_pubkey, roster_revision, admission_id, + generation, } = lifecycle; let content = match (roster_revision, admission_id) { (Some(revision), Some(admission_id)) => serde_json::json!({ "ephemeral_channel_id": channel_id.to_string(), "roster_revision": revision, "admission_id": admission_id.to_string(), + "generation": generation, }), (Some(revision), None) => serde_json::json!({ "ephemeral_channel_id": channel_id.to_string(), "roster_revision": revision, + "generation": generation, }), (None, Some(admission_id)) => serde_json::json!({ "ephemeral_channel_id": channel_id.to_string(), "admission_id": admission_id.to_string(), + "generation": generation, + }), + (None, None) => serde_json::json!({ + "ephemeral_channel_id": channel_id.to_string(), + "generation": generation, }), - (None, None) => serde_json::json!({"ephemeral_channel_id": channel_id.to_string()}), } .to_string(); diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs index 96cc66b4e07..c003db9d8c3 100644 --- a/crates/buzz-relay/src/audio/join.rs +++ b/crates/buzz-relay/src/audio/join.rs @@ -265,6 +265,15 @@ pub enum JoinOutcome { } impl JoinOutcome { + /// Fenced generation carried by both local- and remote-owner outcomes. + #[must_use] + pub const fn generation(&self) -> u64 { + match *self { + JoinOutcome::LocalOwner { generation } + | JoinOutcome::RemoteOwner { generation, .. } => generation, + } + } + /// The fenced header for frames this join produces, given the huddle's /// session id (its channel id) and resolved owner. For a local-owner join /// the owner is this pod (`local_runtime_id`); for a remote-owner join it @@ -1101,6 +1110,29 @@ impl HuddleControlAcceptor { .await } + /// Remove one peer admitted by a remote control stream and perform the + /// same authoritative room-empty teardown as the local owner WebSocket + /// path. The owner-registry release is generation-fenced, so a late close + /// from an old stream cannot cancel a newly acquired lease epoch. + fn remove_remote_peer( + &self, + community: CommunityId, + session_id: Uuid, + generation: u64, + peer_id: Uuid, + ) { + let Some(room) = self.rooms.get(community, session_id) else { + return; + }; + let Some((delta, should_end)) = room.remove_peer_and_check_ended(peer_id) else { + return; + }; + broadcast_peer_left(&room, delta, session_id); + if should_end && self.rooms.cleanup_if_empty(community, session_id) { + self.owners.release(session_id, generation); + } + } + /// Serve register/unregister frames for one non-owner pod's stream. /// /// The community is learned from the first `RegisterPeer` frame and latched @@ -1297,13 +1329,13 @@ impl HuddleControlAcceptor { } HuddleControlMsg::UnregisterPeer { pubkey } => { if let Some(peer_id) = registered.remove(&pubkey) { - if let Some(room) = stream_community.and_then(|community_id| { - self.rooms - .get(CommunityId::from_uuid(community_id), session_id) - }) { - if let Some(delta) = room.remove_peer(peer_id) { - broadcast_peer_left(&room, delta, session_id); - } + if let Some(community_id) = stream_community { + self.remove_remote_peer( + CommunityId::from_uuid(community_id), + session_id, + fenced.generation, + peer_id, + ); } } } @@ -1347,14 +1379,10 @@ impl HuddleControlAcceptor { // Teardown: drop every peer this stream registered, regardless of how // the loop ended. Dropping the peer drops its `audio_tx`, which ends the // matching `spawn_remote_peer_sink` task. - if let Some(room) = stream_community.and_then(|community_id| { - self.rooms - .get(CommunityId::from_uuid(community_id), session_id) - }) { + if let Some(community_id) = stream_community { + let community = CommunityId::from_uuid(community_id); for (_pubkey, peer_id) in registered { - if let Some(delta) = room.remove_peer(peer_id) { - broadcast_peer_left(&room, delta, session_id); - } + self.remove_remote_peer(community, session_id, fenced.generation, peer_id); } } result @@ -2411,6 +2439,54 @@ mod tests { assert_eq!(room.peer_pubkeys(), vec![("owner-local".into(), 0)]); } + #[tokio::test] + async fn remote_only_stream_close_releases_owner_room_and_lease() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let dir = Arc::new(FakeDir::default()); + let owners = Arc::new(HuddleOwnerRegistry::new()); + owners.attach_signals(session_id, Arc::clone(&dir), lease_for(session_id, 7)); + + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::clone(&dir), + owner_rt, + Arc::clone(&owners), + ); + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterPeer { + community_id: *community().as_uuid(), + pubkey: "remote-only".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + let registered = client.recv_frame().await.unwrap().unwrap(); + assert!(matches!(registered, MeshStreamFrame::Data { .. })); + assert!(rooms.get(community(), session_id).is_some()); + assert!(owners.lost_for(session_id).is_some()); + + drop(client); + served.await.unwrap().unwrap(); + + assert!(rooms.get(community(), session_id).is_none()); + assert!(owners.lost_for(session_id).is_none()); + await_release_calls(&dir, 1).await; + } + /// A `RegisterPeer` whose fence is rejected (wrong community keys a lease /// Redis never wrote) yields a `RegisterRejected(Fenced(..))` reply — no /// peer admitted — and the stream stays alive for the client to close. diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 5fcfe70b91c..e284e7fa6a2 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -14,12 +14,13 @@ use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; use uuid::Uuid; -use buzz_auth::{generate_challenge, AuthContext, LimitType}; +use buzz_auth::{generate_challenge, AuthContext}; use buzz_core::tenant::TenantContext; use nostr::Filter; use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; +use crate::rejection::{enforce_ws_admission, request_rejection_message, RejectionTarget}; use crate::state::{ run_registered_community_connection, AppState, CommunityConnectionControl, CommunityDisconnectReason, @@ -571,7 +572,10 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let permit = match state.handler_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { - conn.send(RelayMessage::notice( + // Correlate to the event id: a bare NOTICE here strands the + // client's pending publish exactly as an over-quota one did. + conn.send(request_rejection_message( + RejectionTarget::Event(event.id), "rate-limited: too many concurrent requests", )); return; @@ -593,14 +597,18 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar .instrument(span), ); } - ClientMessage::Req { sub_id, filters } => { + ClientMessage::Req { + sub_id, + filters, + before_ids, + } => { let conn = Arc::clone(&conn); let state = Arc::clone(&state); let permit = match state.handler_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { conn.send(request_rejection_message( - Some(&sub_id), + RejectionTarget::Subscription(&sub_id), "rate-limited: too many concurrent requests", )); return; @@ -609,7 +617,7 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let span = tracing::info_span!("ws.req", conn_id = %conn.conn_id, sub_id = %sub_id); tokio::spawn( async move { - handlers::req::handle_req(sub_id, filters, conn, state).await; + handlers::req::handle_req(sub_id, filters, before_ids, conn, state).await; drop(permit); } .instrument(span), @@ -621,7 +629,8 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let permit = match state.handler_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { - conn.send(RelayMessage::notice( + conn.send(request_rejection_message( + RejectionTarget::Subscription(&sub_id), "rate-limited: too many concurrent requests", )); return; @@ -642,104 +651,139 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar } } -fn request_rejection_message(sub_id: Option<&str>, reason: &str) -> String { - match sub_id { - Some(sub_id) => RelayMessage::closed(sub_id, reason), - None => RelayMessage::notice(reason), +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + use buzz_auth::AuthMethod; + use nostr::{EventBuilder, Keys, Kind}; + + /// A connection whose outbound frames a test can read back. + /// + /// Lives here, next to `ConnectionState`, so the crate has one place that + /// knows how to build one. Shared with `crate::rejection`'s tests. + pub(crate) fn test_conn_with_auth( + auth: AuthState, + ) -> (Arc, mpsc::Receiver) { + let (send_tx, send_rx) = mpsc::channel(4); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(4); + let conn = ConnectionState { + conn_id: Uuid::new_v4(), + tenant: TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), + auth_state: RwLock::new(auth), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }; + (Arc::new(conn), send_rx) } -} -async fn enforce_ws_admission( - msg: &ClientMessage, - conn: &ConnectionState, - state: &AppState, -) -> bool { - let is_event = matches!(msg, ClientMessage::Event(_)); - if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) { - return true; + /// An authenticated connection — the only state admission quotas apply to. + pub(crate) fn authenticated_state() -> AuthState { + AuthState::Authenticated(AuthContext { + pubkey: Keys::generate().public_key(), + scopes: Vec::new(), + channel_ids: None, + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: None, + }) } - let (pubkey, is_agent) = { - let auth = conn.auth_state.read().await; - match &*auth { - AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()), - _ => return true, + pub(crate) fn read_frame(rx: &mut mpsc::Receiver) -> serde_json::Value { + match rx.try_recv().expect("a frame was sent") { + WsMessage::Text(text) => serde_json::from_str(&text).expect("valid JSON frame"), + other => panic!("unexpected websocket message: {other:?}"), } - }; - - let limits = &state.auth.config().rate_limits; - let (ws_window_secs, ws_limit) = - crate::admission::ws_admission_budget(limits.human_ws_events_per_sec); - let ws_result = crate::admission::check_principal( - state.admission_rate_limiter.as_ref(), - &conn.tenant, - &pubkey, - LimitType::WsEvents, - ws_window_secs, - ws_limit, - ) - .await; - let sub_id = match msg { - ClientMessage::Req { sub_id, .. } => Some(sub_id.as_str()), - _ => None, - }; - if !send_admission_result(conn, ws_result, sub_id) { - return false; } - if is_event { - let message_limit = if is_agent { - limits.agent_standard_messages_per_min - } else { - limits.human_messages_per_min - }; - let message_result = crate::admission::check_principal( - state.admission_rate_limiter.as_ref(), - &conn.tenant, - &pubkey, - LimitType::Messages, - 60, - message_limit, - ) - .await; - if !send_admission_result(conn, message_result, None) { - return false; - } + /// Drives the real `handle_text_message` with every handler permit held, so + /// the EVENT saturation branch is reached through production dispatch rather + /// than by calling its helpers directly. + /// + /// This must go through `handle_text_message`: a test that renders the + /// rejection frame itself stays green when the call site inside the match + /// arm is reverted to a bare `NOTICE`. + #[tokio::test] + async fn saturated_handler_rejects_an_event_on_the_ok_channel() { + let state = crate::state::tests::test_state().await; + // An unauthenticated connection skips the admission quotas, so the + // semaphore is the only gate the frame can trip. + let (conn, mut rx) = test_conn_with_auth(AuthState::Failed); + + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); + + let event = EventBuilder::new(Kind::TextNote, "hello") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let event_id = event.id.to_hex(); + let raw = serde_json::json!(["EVENT", event]).to_string(); + + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + let frame = read_frame(&mut rx); + assert_eq!( + frame[0], "OK", + "an EVENT turned away for handler saturation must be rejected on the \ + OK channel — a NOTICE carries no event id, so the client's pending \ + publish cannot be settled and the send only times out" + ); + assert_eq!(frame[1], event_id); + assert_eq!(frame[2], false); + assert_eq!(frame[3], "rate-limited: too many concurrent requests"); } - true -} + /// The REQ arm of the same branch still settles on CLOSED. + #[tokio::test] + async fn saturated_handler_rejects_a_req_on_the_closed_channel() { + let state = crate::state::tests::test_state().await; + let (conn, mut rx) = test_conn_with_auth(AuthState::Failed); -fn send_admission_result( - conn: &ConnectionState, - result: Result<(), crate::admission::AdmissionError>, - sub_id: Option<&str>, -) -> bool { - match result { - Ok(()) => true, - Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1); - conn.send(request_rejection_message( - sub_id, - &format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), - )); - false - } - Err(crate::admission::AdmissionError::Unavailable) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1); - conn.send(request_rejection_message( - sub_id, - "rate-limited: shared admission unavailable", - )); - false - } + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); + + let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + let frame = read_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "history-abc"); } -} -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Arc, Mutex}; + /// COUNT refusals follow NIP-45 and close the named query. + #[tokio::test] + async fn saturated_handler_rejects_a_count_on_the_closed_channel() { + let state = crate::state::tests::test_state().await; + let (conn, mut rx) = test_conn_with_auth(AuthState::Failed); + + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); + + let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + let frame = read_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "count-abc"); + assert_eq!(frame[2], "rate-limited: too many concurrent requests"); + } #[derive(Debug, Default)] struct MockSinkState { @@ -834,19 +878,6 @@ mod tests { .collect() } - #[test] - fn req_rejections_are_subscription_scoped() { - let reason = "rate-limited: too many concurrent requests"; - let closed: serde_json::Value = - serde_json::from_str(&request_rejection_message(Some("history-123"), reason)) - .expect("parse CLOSED"); - assert_eq!(closed, serde_json::json!(["CLOSED", "history-123", reason])); - - let notice: serde_json::Value = - serde_json::from_str(&request_rejection_message(None, reason)).expect("parse NOTICE"); - assert_eq!(notice, serde_json::json!(["NOTICE", reason])); - } - #[tokio::test] async fn send_loop_batches_queued_data_frames_into_one_flush() { let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e0..02e2cc03a64 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -76,6 +76,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // The tag is integrity-protected by the event's Schnorr signature — if // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); + let signed_auth_created_at = event.created_at.as_secs(); let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); @@ -137,6 +138,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if let Some(owner) = crate::api::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) { outcome = match state .db @@ -219,6 +221,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: conn.tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) .await { @@ -246,6 +249,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: crate::api::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) } else { None diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index ae7adc98143..074f6b391d0 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -109,7 +109,7 @@ async fn persist_command_event( let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?; buzz_deletion::store(db) @@ -463,7 +463,7 @@ async fn handle_dm_add_member( // 3. Validate channel is type "dm" let existing_channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; if existing_channel.channel_type != "dm" { @@ -473,7 +473,7 @@ async fn handle_dm_add_member( // 4. Get existing members, merge with new let existing_members = state .db - .get_members(tenant.community(), channel_id) + .get_members_for_event_write(tenant.community(), channel_id) .await .map_err(|e| IngestError::Internal(format!("error: get members: {e}")))?; @@ -593,7 +593,7 @@ async fn handle_dm_hide( // 3. Validate channel is type "dm" let channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; if channel.channel_type != "dm" { @@ -763,7 +763,7 @@ async fn handle_workflow_def( let community_id = tenant.community(); state .db - .get_channel(community_id, channel_id) + .get_channel_for_event_write(community_id, channel_id) .await .map_err(|_| IngestError::Rejected("invalid: workflow channel not found".into()))?; @@ -1367,21 +1367,23 @@ async fn resume_workflow_after_approval( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; async fn persistence_test_context() -> (buzz_db::Db, TenantContext) { let url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 -- local test-only credentials let pool = sqlx::PgPool::connect(&url) .await .expect("connect workflow persistence test database"); let db = buzz_db::Db::from_pool(pool); - db.migrate() - .await - .expect("migrate workflow persistence test database"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate() + .await + .expect("migrate workflow persistence test database"); + } let host = format!("workflow-cas-{}.example", Uuid::new_v4().simple()); let community = db .ensure_configured_community(&host) @@ -1509,7 +1511,9 @@ mod tests { )); let create_revision = create.id.to_hex(); - let mut updates = (0..64).map(|index| { + // Event IDs are hashes, so keep sampling instead of imposing a finite + // cutoff that makes this same-second ordering check probabilistic. + let mut updates = (0_u64..).map(|index| { workflow_event( &keys, workflow_id, @@ -1521,7 +1525,7 @@ mod tests { let update = updates .find(|candidate| candidate.id.as_bytes() < create.id.as_bytes()) .expect("find same-second update that wins NIP-33 ordering"); - let dominated_update = (64..256) + let dominated_update = (64_u64..) .map(|index| { workflow_event( &keys, @@ -1591,7 +1595,10 @@ mod tests { "legacy-malformed", ); - let mut tx = db.begin_transaction().await.expect("begin legacy seed"); + let mut tx = db + .begin_event_write_transaction() + .await + .expect("begin legacy seed"); let (_, was_inserted) = buzz_db::event::insert_event_in_transaction( &mut tx, tenant.community(), diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8bea0..229b5f37161 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -327,7 +327,7 @@ pub async fn provision_community( if let Some(owner_hex) = &initial_owner { state .db - .bootstrap_owner(record.id, owner_hex) + .provision_owner(record.id, owner_hex) .await .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?; publish_membership_snapshot_if_required(state, record.id, &record.host).await; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f3282..66a8ff9e7c0 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -46,7 +46,7 @@ pub(crate) fn bounded_kind_label(kind: u32) -> String { 44200 => kind.to_string(), 45001..=45003 => kind.to_string(), 46001..=46012 | 46020 | 46030..=46031 => kind.to_string(), - 48001 | 48100..=48103 | 48106 => kind.to_string(), + 48001 | 48100..=48104 | 48106 => kind.to_string(), 49001 => kind.to_string(), _ => "other".to_string(), } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index dd2fa6e93e0..ee1d0312be9 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -91,7 +91,7 @@ async fn validate_huddle_lifecycle_event( let backing_channel_id = huddle_backing_channel_id(event)?; let backing = state .db - .get_channel(tenant.community(), backing_channel_id) + .get_channel_for_event_write(tenant.community(), backing_channel_id) .await .map_err(map_huddle_backing_channel_error)?; let signer = event.pubkey.to_bytes(); @@ -122,7 +122,7 @@ async fn validate_huddle_lifecycle_event( })?; let linked = state .db - .huddle_started_link_exists( + .huddle_started_link_exists_for_event_write( tenant.community(), parent_channel_id, backing_channel_id, @@ -597,7 +597,10 @@ pub(crate) async fn derive_reaction_channel( _ => return ReactionChannelResult::NoTarget, }; - match db.get_event_by_id(community_id, &id_bytes).await { + match db + .get_event_by_id_for_event_write(community_id, &id_bytes) + .await + { Ok(Some(target)) => match target.channel_id { Some(ch_id) => ReactionChannelResult::Channel(ch_id), None => ReactionChannelResult::NoChannel, @@ -759,7 +762,7 @@ pub(crate) async fn check_channel_membership( Some(ch) => ch.visibility == "open", None => state .db - .get_channel(tenant.community(), ch_id) + .get_channel_for_event_write(tenant.community(), ch_id) .await .map(|ch| ch.visibility == "open") .unwrap_or(false), @@ -827,7 +830,9 @@ pub(crate) async fn resolve_nip10_thread_meta( hex::decode(&parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; let (parent_event_result, parent_meta_result) = tokio::join!( - state.db.get_event_by_id(community_id, &parent_bytes), + state + .db + .get_event_by_id_for_event_write(community_id, &parent_bytes), state .db .get_thread_metadata_by_event(community_id, &parent_bytes), @@ -863,7 +868,7 @@ pub(crate) async fn resolve_nip10_thread_meta( } let root_ts = if let Ok(Some(root_ev)) = state .db - .get_event_by_id(community_id, &effective_root) + .get_event_by_id_for_event_write(community_id, &effective_root) .await { chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) @@ -947,13 +952,16 @@ async fn derive_ancestry_from_parent_tags( if parent_root.as_slice() == parent_bytes { (parent_root, parent_created, 1) } else { - let root_created = - if let Ok(Some(root_ev)) = state.db.get_event_by_id(community_id, &parent_root).await { - chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) - .unwrap_or(parent_created) - } else { - parent_created - }; + let root_created = if let Ok(Some(root_ev)) = state + .db + .get_event_by_id_for_event_write(community_id, &parent_root) + .await + { + chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) + .unwrap_or(parent_created) + } else { + parent_created + }; (parent_root, root_created, 2) } } @@ -1019,7 +1027,9 @@ pub(crate) async fn resolve_relay_reply_thread_meta( hex::decode(parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; let (parent_event_result, parent_meta_result) = tokio::join!( - state.db.get_event_by_id(community_id, &parent_bytes), + state + .db + .get_event_by_id_for_event_write(community_id, &parent_bytes), state .db .get_thread_metadata_by_event(community_id, &parent_bytes), @@ -1053,7 +1063,7 @@ pub(crate) async fn resolve_relay_reply_thread_meta( parent_created } else if let Ok(Some(root_ev)) = state .db - .get_event_by_id(community_id, &effective_root) + .get_event_by_id_for_event_write(community_id, &effective_root) .await { chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) @@ -1163,7 +1173,7 @@ async fn validate_edit_ownership( hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; let target_event = state .db - .get_event_by_id(community_id, &target_bytes) + .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? .ok_or_else(|| "edit target event not found".to_string())?; @@ -1193,7 +1203,7 @@ async fn validate_edit_ownership( if !is_member { let is_open = state .db - .get_channel(community_id, ch_id) + .get_channel_for_event_write(community_id, ch_id) .await .map(|ch| ch.visibility == "open") .unwrap_or(false); @@ -1244,7 +1254,7 @@ async fn validate_forum_vote_target( hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; let target_event = state .db - .get_event_by_id(community_id, &target_bytes) + .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? .ok_or_else(|| "vote target event not found".to_string())?; @@ -2435,7 +2445,7 @@ async fn ingest_event_inner( })?; match state .db - .get_event_by_id(tenant.community(), &target_bytes) + .get_event_by_id_for_event_write(tenant.community(), &target_bytes) .await { Ok(Some(target)) => target.channel_id, @@ -2483,7 +2493,11 @@ async fn ingest_event_inner( // it later in this request); each gate keeps its existing missing-row // behavior. let channel_row = match channel_id { - Some(ch_id) => state.db.get_channel(tenant.community(), ch_id).await.ok(), + Some(ch_id) => state + .db + .get_channel_for_event_write(tenant.community(), ch_id) + .await + .ok(), None => None, }; // E1 phase-2 (§4.8 phase-2 addendum): resolve the fan-out visibility once, @@ -3275,7 +3289,7 @@ async fn ingest_event_inner( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Mutex; use super::*; @@ -3528,7 +3542,9 @@ mod tests { .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); let db = buzz_db::Db::from_pool(pool); - db.migrate().await.expect("migrate test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate test DB"); + } let store = buzz_deletion::store(&db); let host = format!("lane3-fence-{}.example", Uuid::new_v4().simple()); diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516d..56f0e78d3c1 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -484,7 +484,7 @@ async fn execute_relay_admin_command( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index d299cc045fa..9a141e86ff1 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -8,7 +8,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, + KIND_DM_VISIBILITY, KIND_HUDDLE_LIVENESS, P_GATED_KINDS, RESULT_GATED_KINDS, + SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -51,6 +52,7 @@ const _: () = assert!(FILTER_QUERY_CONCURRENCY >= 2 && FILTER_QUERY_CONCURRENCY pub async fn handle_req( sub_id: String, filters: Vec, + before_ids: Vec>>, conn: Arc, state: Arc, ) { @@ -208,6 +210,18 @@ pub async fn handle_req( return; } + if filters_are_huddle_liveness_only(&filters) { + handle_huddle_liveness_req( + &sub_id, + &filters, + authorized_requested_channels.as_deref().unwrap_or_default(), + &conn, + &state, + ) + .await; + return; + } + // Applied BEFORE the NIP-50 search branch so that an authenticated member // cannot use `{"search":"...","kinds":[30174]}` (or similar for p-gated // kinds) to harvest indexed-but-globally-stored sensitive events. Search @@ -342,6 +356,7 @@ pub async fn handle_req( }; let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); + params.before_id = before_ids.get(idx).cloned().flatten(); apply_channel_scope_to_query( &mut params, filter, @@ -1133,6 +1148,128 @@ pub(crate) fn extract_channel_ids_from_filters(filters: &[Filter]) -> Option bool { + !filters.is_empty() + && filters.iter().all(|filter| { + filter.kinds.as_ref().is_some_and(|kinds| { + kinds.len() == 1 + && kinds + .iter() + .all(|kind| kind.as_u16() as u32 == KIND_HUDDLE_LIVENESS) + }) + }) +} + +fn huddle_liveness_session_ids(filters: &[Filter]) -> Vec { + let d_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::D); + let mut session_ids = Vec::new(); + for filter in filters { + if let Some(values) = filter.generic_tags.get(&d_tag) { + for value in values { + if let Ok(session_id) = value.parse::() { + if !session_ids.contains(&session_id) { + session_ids.push(session_id); + } + } + } + } + } + session_ids.truncate(MAX_EXPLICIT_CHANNEL_VALUES); + session_ids +} + +async fn handle_huddle_liveness_req( + sub_id: &str, + filters: &[Filter], + parent_channel_ids: &[uuid::Uuid], + conn: &ConnectionState, + state: &AppState, +) { + if parent_channel_ids.is_empty() { + conn.send(RelayMessage::closed( + sub_id, + "restricted: huddle liveness requires an authorized #h channel", + )); + return; + } + + let session_ids = huddle_liveness_session_ids(filters); + let linked_sessions = match state + .db + .huddle_started_links(conn.tenant.community(), parent_channel_ids, &session_ids) + .await + { + Ok(links) => links, + Err(error) => { + warn!("Huddle liveness linkage batch failed: {error}"); + conn.send(RelayMessage::closed(sub_id, "error: database error")); + return; + } + }; + + for (session_id, parent_channel_id, _creator) in linked_sessions { + let generation = if let Some(mesh) = state.mesh() { + match mesh + .directory + .lookup(conn.tenant.community(), session_id) + .await + { + Ok(Some(lease)) if lease.profile == buzz_relay_mesh::Profile::HuddleControl => { + lease.generation.to_string() + } + Ok(_) => continue, + Err(error) => { + warn!(session_id = %session_id, "Huddle liveness lease lookup failed: {error}"); + conn.send(RelayMessage::closed(sub_id, "error: liveness unavailable")); + return; + } + } + } else if state + .audio_rooms + .get(conn.tenant.community(), session_id) + .is_some_and(|room| !room.is_empty()) + { + state.huddle_liveness_generation.to_string() + } else { + continue; + }; + + let session = session_id.to_string(); + let parent = parent_channel_id.to_string(); + let tags = match ( + nostr::Tag::parse(["d", session.as_str()]), + nostr::Tag::parse(["h", parent.as_str()]), + ) { + (Ok(d), Ok(h)) => vec![d, h], + _ => continue, + }; + let content = serde_json::json!({ + "ephemeral_channel_id": session, + "generation": generation, + }) + .to_string(); + let event = match nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_HUDDLE_LIVENESS as u16), + content, + ) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + { + Ok(event) => event, + Err(error) => { + warn!(session_id = %session_id, "Huddle liveness signing failed: {error}"); + conn.send(RelayMessage::closed(sub_id, "error: signing failed")); + return; + } + }; + if !conn.send(RelayMessage::event(sub_id, &event)) { + return; + } + } + + conn.send(RelayMessage::eose(sub_id)); +} + async fn release_subscription_topics( state: &AppState, tenant: &TenantContext, @@ -1418,6 +1555,40 @@ mod tests { use super::*; use nostr::{Alphabet, Filter, SingleLetterTag}; + #[test] + fn huddle_liveness_filters_require_only_the_snapshot_kind() { + let liveness = Filter::new().kind(nostr::Kind::Custom(KIND_HUDDLE_LIVENESS as u16)); + let mixed = liveness.clone().kind(nostr::Kind::Custom( + buzz_core::kind::KIND_HUDDLE_STARTED as u16, + )); + + assert!(filters_are_huddle_liveness_only(&[liveness])); + assert!(!filters_are_huddle_liveness_only(&[mixed])); + assert!(!filters_are_huddle_liveness_only(&[])); + } + + #[test] + fn huddle_liveness_session_ids_are_deduplicated_and_bounded() { + let d_tag = SingleLetterTag::lowercase(Alphabet::D); + let input = (0..MAX_EXPLICIT_CHANNEL_VALUES + 16) + .map(|_| uuid::Uuid::new_v4()) + .collect::>(); + let first = input.iter().fold(Filter::new(), |filter, session_id| { + filter.custom_tag(d_tag, session_id.to_string()) + }); + let second = Filter::new() + .custom_tag(d_tag, input[0].to_string()) + .custom_tag(d_tag, input[1].to_string()); + + let extracted = huddle_liveness_session_ids(&[first, second]); + let extracted_set = extracted.iter().copied().collect::>(); + let input_set = input.iter().copied().collect::>(); + + assert_eq!(extracted.len(), MAX_EXPLICIT_CHANNEL_VALUES); + assert_eq!(extracted_set.len(), extracted.len()); + assert!(extracted_set.is_subset(&input_set)); + } + #[test] fn global_queries_push_access_scope_before_limit() { let accessible = vec![uuid::Uuid::new_v4(), uuid::Uuid::new_v4()]; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index d3416d673c5..8183fe98d80 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -147,7 +147,10 @@ async fn evict_non_member_channel_subscriptions( state: &Arc, channel_id: Uuid, ) -> anyhow::Result<()> { - let members = state.db.get_members(tenant.community(), channel_id).await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let member_pubkeys: std::collections::HashSet> = members.into_iter().map(|m| m.pubkey).collect(); @@ -262,7 +265,7 @@ pub async fn validate_standard_deletion_event( for target_id in target_ids { let target_event = state .db - .get_event_by_id_including_deleted(tenant.community(), &target_id) + .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) .await? .ok_or_else(|| anyhow::anyhow!("target event not found"))?; @@ -327,7 +330,7 @@ pub async fn validate_admin_event( // (unarchive), which must be allowed through so the channel can be restored. let channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| anyhow::anyhow!("channel not found"))?; let is_unarchive_request = kind == 9002 @@ -655,7 +658,7 @@ pub async fn validate_admin_event( // BEFORE storage. Fail closed: missing target → reject. let target_event = state .db - .get_event_by_id(tenant.community(), &target_id) + .get_event_by_id_for_event_write(tenant.community(), &target_id) .await .map_err(|e| anyhow::anyhow!("db error looking up target: {e}"))? .ok_or_else(|| anyhow::anyhow!("target event not found"))?; @@ -687,7 +690,7 @@ pub async fn validate_admin_event( } let is_open = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map(|ch| ch.visibility == "open") .unwrap_or(false); @@ -1015,7 +1018,7 @@ async fn emit_addressable_discovery_event( let min_ts = { let existing = state .db - .query_events(&buzz_db::event::EventQuery { + .query_events_for_event_write(&buzz_db::event::EventQuery { kinds: Some(vec![kind as i32]), channel_id: Some(channel_id), limit: Some(1), @@ -1129,8 +1132,14 @@ pub async fn emit_group_discovery_events( state: &Arc, channel_id: Uuid, ) -> anyhow::Result<()> { - let channel = state.db.get_channel(tenant.community(), channel_id).await?; - let members = state.db.get_members(tenant.community(), channel_id).await?; + let channel = state + .db + .get_channel_for_event_write(tenant.community(), channel_id) + .await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes()); let group_id = channel_id.to_string(); @@ -1376,7 +1385,7 @@ async fn handle_put_user( .map_err(|_| anyhow::anyhow!("invalid role: {role_str}"))?, None => state .db - .get_members(tenant.community(), channel_id) + .get_members_for_event_write(tenant.community(), channel_id) .await? .iter() .find(|m| m.pubkey == target_pubkey) @@ -1446,7 +1455,10 @@ async fn handle_remove_user( // Guard: prevent last-owner orphaning on self-removal (kind 9001). if target_pubkey == actor_bytes { - let members = state.db.get_members(tenant.community(), channel_id).await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let owner_count = members.iter().filter(|m| m.role == "owner").count(); let actor_is_owner = members .iter() @@ -1581,7 +1593,7 @@ async fn handle_edit_metadata( "visibility" => { let was_open = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map(|c| c.visibility == "open") .unwrap_or(false); @@ -1701,8 +1713,10 @@ async fn handle_edit_metadata( // same channel by the same actor could collide ids and skip a fan-out. // Not reachable in practice — unarchive has a single human-driven caller; // the reaper only auto-archives — so we don't engineer around it. - for member in - state.db.get_members(tenant.community(), channel_id).await? + for member in state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await? { if let Err(e) = emit_membership_notification( tenant, @@ -1770,7 +1784,7 @@ async fn handle_delete_event_side_effect( // by sending h=A, e=. if let Some(target_event) = state .db - .get_event_by_id_including_deleted(tenant.community(), &target_id) + .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) .await .map_err(|e| anyhow::anyhow!("get_event_by_id failed: {e}"))? { @@ -1872,7 +1886,11 @@ async fn handle_create_group( // no-h-tag path, ingest never creates the channel, so this is the sole // increment. let channel = if let Some(client_uuid) = extract_h_tag_channel(event) { - match state.db.get_channel(tenant.community(), client_uuid).await { + match state + .db + .get_channel_for_event_write(tenant.community(), client_uuid) + .await + { Ok(ch) => ch, Err(_) => { // Channel not found — shouldn't happen (ingest_event pre-created it), @@ -2026,7 +2044,7 @@ async fn handle_join_request( // Only open channels allow self-join via kind:9021. let channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| anyhow::anyhow!("channel not found"))?; if channel.visibility != "open" { @@ -2104,7 +2122,10 @@ async fn handle_leave_request( let actor_bytes = event.pubkey.to_bytes().to_vec(); // Guard: prevent last-owner orphaning on leave. - let members = state.db.get_members(tenant.community(), channel_id).await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let owner_count = members.iter().filter(|m| m.role == "owner").count(); let actor_is_owner = members .iter() @@ -2315,7 +2336,7 @@ async fn handle_standard_deletion_event( for target_id in target_ids { let target_event = match state .db - .get_event_by_id_including_deleted(tenant.community(), &target_id) + .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) .await? { Some(target) => target, @@ -2393,7 +2414,7 @@ async fn handle_standard_deletion_event( if let Ok(react_target_id) = hex::decode(&react_target_hex) { if let Ok(Some(react_target_event)) = state .db - .get_event_by_id(tenant.community(), &react_target_id) + .get_event_by_id_for_event_write(tenant.community(), &react_target_id) .await { let react_target_ts = chrono::DateTime::from_timestamp( @@ -3030,22 +3051,60 @@ async fn emit_initial_ref_state( /// safe to run at startup and periodically without producing an event stream /// when nothing changed. A failure in one community is logged and counted but /// does not prevent the remaining communities from being repaired. +#[derive(Clone, Copy)] +pub enum Nip43ReconciliationPurpose { + /// Before listener admission opens. + Bootstrap, + /// Periodic background repair after startup. + Maintenance, +} + +/// Preserve the original maintenance reconciliation API for downstream callers. +#[deprecated(note = "use reconcile_nip43_membership_snapshots_with_purpose")] pub async fn reconcile_nip43_membership_snapshots(state: &Arc) -> anyhow::Result { - let communities = state.db.usage_community_hosts().await?; + reconcile_nip43_membership_snapshots_with_purpose( + state, + Nip43ReconciliationPurpose::Maintenance, + ) + .await +} + +/// Reconcile NIP-43 snapshots with explicit startup or maintenance attribution. +pub async fn reconcile_nip43_membership_snapshots_with_purpose( + state: &Arc, + purpose: Nip43ReconciliationPurpose, +) -> anyhow::Result { + let communities = match purpose { + Nip43ReconciliationPurpose::Bootstrap => state.db.bootstrap_community_hosts().await?, + Nip43ReconciliationPurpose::Maintenance => state.db.usage_community_hosts().await?, + }; let mut reconciled = 0usize; for community in communities { let community_id = buzz_core::CommunityId::from_uuid(community.id); let host = community.host; let result = async { - if !state - .db - .nip43_membership_snapshot_needs_reconciliation( - community_id, - &state.relay_keypair.public_key(), - ) - .await? - { + let needs_reconciliation = match purpose { + Nip43ReconciliationPurpose::Bootstrap => { + state + .db + .nip43_membership_snapshot_needs_reconciliation_for_bootstrap( + community_id, + &state.relay_keypair.public_key(), + ) + .await? + } + Nip43ReconciliationPurpose::Maintenance => { + state + .db + .nip43_membership_snapshot_needs_reconciliation_for_maintenance( + community_id, + &state.relay_keypair.public_key(), + ) + .await? + } + }; + if !needs_reconciliation { return Ok::(false); } @@ -3270,7 +3329,10 @@ pub async fn reconcile_channel_events( ) -> anyhow::Result<()> { use buzz_db::event::EventQuery; - let channels = state.db.list_channels(tenant.community(), None).await?; + let channels = state + .db + .list_channels_for_bootstrap(tenant.community(), None) + .await?; if channels.is_empty() { return Ok(()); } @@ -3281,7 +3343,7 @@ pub async fn reconcile_channel_events( let channel_id_str = channel.id.to_string(); let existing = match state .db - .query_events(&EventQuery { + .query_events_for_bootstrap(&EventQuery { kinds: Some(vec![39000]), d_tag: Some(channel_id_str.clone()), limit: Some(1), @@ -3414,7 +3476,7 @@ pub async fn publish_nipia_archival_list( let now = nostr::Timestamp::now().as_secs(); let previous = state .db - .query_events(&buzz_db::event::EventQuery { + .query_events_for_event_write(&buzz_db::event::EventQuery { kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]), pubkey: Some(relay_pubkey.to_bytes().to_vec()), limit: Some(1), @@ -3517,7 +3579,7 @@ pub async fn publish_dm_visibility_snapshot( let ts = { let existing = state .db - .query_events(&buzz_db::event::EventQuery { + .query_events_for_event_write(&buzz_db::event::EventQuery { kinds: Some(vec![KIND_DM_VISIBILITY as i32]), pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), d_tag: Some(viewer_hex.clone()), @@ -3684,6 +3746,16 @@ pub async fn publish_nipia_unarchived( mod tests { use super::*; + #[test] + fn nip43_reconciliation_compatibility_alias_is_preserved() { + #[allow(deprecated)] + async fn call(state: &Arc) -> anyhow::Result { + reconcile_nip43_membership_snapshots(state).await + } + + let _ = call; + } + #[test] fn group_members_snapshot_keeps_members_past_one_thousand() { let channel_id = Uuid::new_v4(); diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 800433a8498..18ea187fc7d 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -4,6 +4,7 @@ mod admission; mod build_info; +mod rejection; /// REST API route handlers. pub mod api; @@ -24,6 +25,8 @@ pub mod error; pub mod handlers; /// Stateless HMAC-signed relay invite tokens (mint/verify). pub mod invite_token; +/// Fixed-schema evidence for the relay's earliest startup steps. +pub mod lifecycle; /// Inter-relay mesh startup wiring (`BUZZ_MESH` seam). pub mod mesh_boot; /// Prometheus metrics: recorder, upkeep, HTTP middleware. @@ -34,6 +37,7 @@ pub mod nip11; pub mod protocol; /// Durable NIP-PL matcher and delivery worker. pub mod push_runtime; +mod readiness; /// Axum router construction. pub mod router; /// Shared application state. @@ -45,6 +49,8 @@ pub mod subscription; pub mod telemetry; /// Row-zero host binding: resolve the request community from the connection host. pub mod tenant; +#[cfg(test)] +mod test_support; /// Relay-side tunnel session directory and routing. pub mod tunnel; /// Webhook secret generation and constant-time comparison. diff --git a/crates/buzz-relay/src/lifecycle.rs b/crates/buzz-relay/src/lifecycle.rs new file mode 100644 index 00000000000..bc9d51062b2 --- /dev/null +++ b/crates/buzz-relay/src/lifecycle.rs @@ -0,0 +1,591 @@ +//! Fixed-schema evidence for the relay's earliest startup steps. +//! +//! These events are written directly to stderr because crypto, tracing, +//! configuration, and metrics setup can fail before the normal telemetry +//! stack exists. Values are closed enums; raw errors and secrets never enter +//! the lifecycle schema. + +use std::{ + io::Write as _, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use serde::Serialize; +use uuid::Uuid; + +const EVENT_NAME: &str = "buzz_process_lifecycle"; +const SCHEMA_VERSION: u8 = 1; + +/// A bounded early-startup phase. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StartupPhase { + /// Process entry through a usable metrics listener. + ProcessTelemetry, + /// Install the process-wide rustls provider. + CryptoInit, + /// Install structured logging and optional OTLP tracing. + TracingInit, + /// Parse environment-backed configuration. + ConfigLoad, + /// Load and validate relay key material. + KeyLoad, + /// Install the Prometheus recorder and bind its listener. + MetricsBind, +} + +impl StartupPhase { + /// The complete wire vocabulary. + pub const ALL: [Self; 6] = [ + Self::ProcessTelemetry, + Self::CryptoInit, + Self::TracingInit, + Self::ConfigLoad, + Self::KeyLoad, + Self::MetricsBind, + ]; + + /// Stable wire value. + pub const fn as_str(self) -> &'static str { + match self { + Self::ProcessTelemetry => "process_telemetry", + Self::CryptoInit => "crypto_init", + Self::TracingInit => "tracing_init", + Self::ConfigLoad => "config_load", + Self::KeyLoad => "key_load", + Self::MetricsBind => "metrics_bind", + } + } +} + +/// A bounded terminal status. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LifecycleStatus { + /// Required work completed. + Succeeded, + /// Optional work failed and startup may continue. + Degraded, + /// Required work failed. + Failed, + /// Control flow dropped the phase without an explicit terminal. + Abandoned, +} + +impl LifecycleStatus { + #[cfg(test)] + const ALL: [Self; 4] = [ + Self::Succeeded, + Self::Degraded, + Self::Failed, + Self::Abandoned, + ]; + + const fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "succeeded", + Self::Degraded => "degraded", + Self::Failed => "failed", + Self::Abandoned => "abandoned", + } + } +} + +/// A secret-safe terminal reason. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LifecycleReason { + /// Tokio runtime construction failed. + RuntimeBuild, + /// Another rustls provider was already installed. + ProviderConflict, + /// The optional OTLP exporter could not be built. + ExporterBuild, + /// Required configuration was missing, malformed, or unusable. + ConfigInvalid, + /// A required value was missing. + Missing, + /// A required value was invalid. + RequiredInvalid, + /// A required listener could not bind. + Bind, + /// A global metrics recorder already existed. + RecorderConflict, + /// A phase owner disappeared without a terminal. + OwnerDropped, + /// A panic unwound through the phase. + Panic, +} + +impl LifecycleReason { + #[cfg(test)] + const ALL: [Self; 10] = [ + Self::RuntimeBuild, + Self::ProviderConflict, + Self::ExporterBuild, + Self::ConfigInvalid, + Self::Missing, + Self::RequiredInvalid, + Self::Bind, + Self::RecorderConflict, + Self::OwnerDropped, + Self::Panic, + ]; + + /// Stable wire value. + pub const fn as_str(self) -> &'static str { + match self { + Self::RuntimeBuild => "runtime_build", + Self::ProviderConflict => "provider_conflict", + Self::ExporterBuild => "exporter_build", + Self::ConfigInvalid => "config_invalid", + Self::Missing => "missing", + Self::RequiredInvalid => "required_invalid", + Self::Bind => "bind", + Self::RecorderConflict => "recorder_conflict", + Self::OwnerDropped => "owner_dropped", + Self::Panic => "panic", + } + } +} + +#[derive(Clone, Debug, Serialize)] +struct LifecycleEvent { + event_name: &'static str, + schema_version: u8, + process_boot_id: Uuid, + sequence: u64, + track: &'static str, + phase: &'static str, + edge: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'static str>, + process_started_at_unix_ms: u64, + observed_at_unix_ms: u64, + process_elapsed_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + phase_elapsed_ms: Option, +} + +trait EventWriter: Send + Sync { + fn emit(&self, event: &LifecycleEvent); +} + +struct StderrWriter; + +impl EventWriter for StderrWriter { + fn emit(&self, event: &LifecycleEvent) { + // Best effort: reporting a startup error must never create another + // panic. This sink intentionally ignores RUST_LOG filters. + let mut stderr = std::io::stderr().lock(); + if serde_json::to_writer(&mut stderr, event).is_ok() { + let _ = stderr.write_all(b"\n"); + } + } +} + +struct ProcessLifecycle { + boot_id: Uuid, + sequence: AtomicU64, + wall_origin: SystemTime, + monotonic_origin: Instant, + writer: Arc, +} + +impl ProcessLifecycle { + fn new(writer: Arc) -> Arc { + let wall_origin = SystemTime::now(); + let monotonic_origin = Instant::now(); + Arc::new(Self { + boot_id: Uuid::new_v4(), + sequence: AtomicU64::new(1), + wall_origin, + monotonic_origin, + writer, + }) + } + + fn start(self: &Arc, phase: StartupPhase) -> PhaseGuard { + let started_at = if phase == StartupPhase::ProcessTelemetry { + self.monotonic_origin + } else { + Instant::now() + }; + self.emit(phase, "started", None, None, None); + PhaseGuard { + lifecycle: Arc::clone(self), + phase, + started_at, + finished: false, + } + } + + fn emit( + &self, + phase: StartupPhase, + edge: &'static str, + status: Option, + reason: Option, + elapsed: Option, + ) { + self.writer.emit(&LifecycleEvent { + event_name: EVENT_NAME, + schema_version: SCHEMA_VERSION, + process_boot_id: self.boot_id, + sequence: self.sequence.fetch_add(1, Ordering::Relaxed), + track: "startup", + phase: phase.as_str(), + edge, + status: status.map(LifecycleStatus::as_str), + reason: reason.map(LifecycleReason::as_str), + process_started_at_unix_ms: millis_since_epoch(self.wall_origin), + observed_at_unix_ms: millis_since_epoch(SystemTime::now()), + process_elapsed_ms: saturating_millis(self.monotonic_origin.elapsed()), + phase_elapsed_ms: elapsed.map(saturating_millis), + }); + } +} + +/// Owns one phase from its start event through exactly one terminal. +pub struct PhaseGuard { + lifecycle: Arc, + phase: StartupPhase, + started_at: Instant, + finished: bool, +} + +impl PhaseGuard { + /// Record successful completion. + pub fn succeed(self) { + self.finish(LifecycleStatus::Succeeded, None); + } + + /// Record an allowed degradation. + pub fn degrade(self, reason: LifecycleReason) { + self.finish(LifecycleStatus::Degraded, Some(reason)); + } + + /// Record a fatal failure. + pub fn fail(self, reason: LifecycleReason) { + self.finish(LifecycleStatus::Failed, Some(reason)); + } + + fn finish(mut self, status: LifecycleStatus, reason: Option) { + let elapsed = self.started_at.elapsed(); + self.lifecycle + .emit(self.phase, "terminal", Some(status), reason, Some(elapsed)); + self.finished = true; + } +} + +impl Drop for PhaseGuard { + fn drop(&mut self) { + if self.finished { + return; + } + let (status, reason) = if std::thread::panicking() { + (LifecycleStatus::Failed, LifecycleReason::Panic) + } else { + (LifecycleStatus::Abandoned, LifecycleReason::OwnerDropped) + }; + self.lifecycle.emit( + self.phase, + "terminal", + Some(status), + Some(reason), + Some(self.started_at.elapsed()), + ); + self.finished = true; + } +} + +/// Tracks the aggregate early-startup phase and its fixed subphases. +pub struct BootTracker { + lifecycle: Arc, + headline: PhaseGuard, + degraded: Option, +} + +impl BootTracker { + /// Start lifecycle accounting before constructing Tokio. + pub fn start_before_runtime( + build: impl FnOnce() -> Result, + ) -> Result<(Runtime, Self), Error> { + Self::start_before_runtime_with_writer(Arc::new(StderrWriter), build) + } + + fn start_before_runtime_with_writer( + writer: Arc, + build: impl FnOnce() -> Result, + ) -> Result<(Runtime, Self), Error> { + let lifecycle = ProcessLifecycle::new(writer); + let boot = Self { + headline: lifecycle.start(StartupPhase::ProcessTelemetry), + lifecycle, + degraded: None, + }; + match build() { + Ok(runtime) => Ok((runtime, boot)), + Err(error) => { + boot.fail(LifecycleReason::RuntimeBuild); + Err(error) + } + } + } + + /// Start a fixed early-startup subphase. + #[must_use = "dropping a phase guard emits an abandoned terminal"] + pub fn start(&self, phase: StartupPhase) -> PhaseGuard { + assert_ne!(phase, StartupPhase::ProcessTelemetry); + self.lifecycle.start(phase) + } + + /// Run a required phase and atomically terminalize both it and startup on failure. + pub fn run_required( + self, + phase: StartupPhase, + work: impl FnOnce() -> Result, + classify: impl FnOnce(&Error) -> LifecycleReason, + ) -> Result<(Self, T), Error> { + let phase_guard = self.start(phase); + match work() { + Ok(value) => { + phase_guard.succeed(); + Ok((self, value)) + } + Err(error) => { + let reason = classify(&error); + phase_guard.fail(reason); + self.fail(reason); + Err(error) + } + } + } + + /// Preserve the first optional degradation for the aggregate terminal. + pub fn mark_degraded(&mut self, reason: LifecycleReason) { + self.degraded.get_or_insert(reason); + } + + /// Finish early startup with a structured lifecycle terminal. + pub fn finish(self) { + let status = if self.degraded.is_some() { + LifecycleStatus::Degraded + } else { + LifecycleStatus::Succeeded + }; + self.headline.finish(status, self.degraded); + } + + fn fail(self, reason: LifecycleReason) { + self.headline.fail(reason); + } +} + +fn millis_since_epoch(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .map(saturating_millis) + .unwrap_or(0) +} + +fn saturating_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{panic::AssertUnwindSafe, sync::Mutex}; + + #[derive(Default)] + struct CapturingWriter(Mutex>); + + impl EventWriter for CapturingWriter { + fn emit(&self, event: &LifecycleEvent) { + self.0.lock().expect("capturing writer").push(event.clone()); + } + } + + fn recorder() -> (Arc, Arc) { + let writer = Arc::new(CapturingWriter::default()); + (ProcessLifecycle::new(writer.clone()), writer) + } + + fn events(writer: &CapturingWriter) -> Vec { + writer.0.lock().expect("capturing writer").clone() + } + + #[test] + fn explicit_and_dropped_terminals_are_exactly_once() { + let (lifecycle, writer) = recorder(); + lifecycle.start(StartupPhase::ConfigLoad).succeed(); + drop(lifecycle.start(StartupPhase::KeyLoad)); + + let events = events(&writer); + assert_eq!(events.len(), 4); + assert_eq!(events[0].sequence, 1); + assert_eq!(events[1].status, Some("succeeded")); + assert_eq!(events[3].status, Some("abandoned")); + assert_eq!(events[3].reason, Some("owner_dropped")); + } + + #[test] + fn panic_unwind_is_bounded() { + let (lifecycle, writer) = recorder(); + let panic = std::panic::catch_unwind(AssertUnwindSafe(|| { + let _phase = lifecycle.start(StartupPhase::CryptoInit); + panic!("controlled test panic"); + })); + assert!(panic.is_err()); + let events = events(&writer); + assert_eq!(events[1].status, Some("failed")); + assert_eq!(events[1].reason, Some("panic")); + } + + #[test] + fn runtime_failure_terminalizes_the_headline() { + let writer = Arc::new(CapturingWriter::default()); + let result = BootTracker::start_before_runtime_with_writer( + writer.clone(), + || -> Result<(), &'static str> { Err("controlled") }, + ); + assert!(matches!(result, Err("controlled"))); + let events = events(&writer); + assert_eq!(events.len(), 2); + assert_eq!(events[1].phase, "process_telemetry"); + assert_eq!(events[1].reason, Some("runtime_build")); + } + + #[test] + fn aggregate_preserves_optional_degradation() { + let (lifecycle, writer) = recorder(); + let mut boot = BootTracker { + headline: lifecycle.start(StartupPhase::ProcessTelemetry), + lifecycle, + degraded: None, + }; + boot.mark_degraded(LifecycleReason::ExporterBuild); + boot.finish(); + let events = events(&writer); + assert_eq!(events[1].status, Some("degraded")); + assert_eq!(events[1].reason, Some("exporter_build")); + } + + #[test] + fn required_failure_terminalizes_subphase_and_headline() { + let (lifecycle, writer) = recorder(); + let boot = BootTracker { + headline: lifecycle.start(StartupPhase::ProcessTelemetry), + lifecycle, + degraded: None, + }; + let result = boot.run_required( + StartupPhase::MetricsBind, + || -> Result<(), &'static str> { Err("controlled") }, + |_error| LifecycleReason::RecorderConflict, + ); + assert!(matches!(result, Err("controlled"))); + + let events = events(&writer); + assert_eq!(events.len(), 4); + assert_eq!(events[2].phase, "metrics_bind"); + assert_eq!(events[2].status, Some("failed")); + assert_eq!(events[2].reason, Some("recorder_conflict")); + assert_eq!(events[3].phase, "process_telemetry"); + assert_eq!(events[3].status, Some("failed")); + assert_eq!(events[3].reason, Some("recorder_conflict")); + } + + #[test] + fn schema_and_vocabulary_are_frozen() { + assert_eq!( + StartupPhase::ALL.map(StartupPhase::as_str), + [ + "process_telemetry", + "crypto_init", + "tracing_init", + "config_load", + "key_load", + "metrics_bind", + ] + ); + let (lifecycle, writer) = recorder(); + drop(lifecycle.start(StartupPhase::ConfigLoad)); + let values: Vec<_> = events(&writer) + .iter() + .map(|event| serde_json::to_value(event).expect("serialize lifecycle event")) + .collect(); + assert_eq!(values[0]["schema_version"], SCHEMA_VERSION); + assert_eq!(values[0]["event_name"], EVENT_NAME); + assert_eq!(values[1]["status"], "abandoned"); + let mut started_keys: Vec<_> = values[0] + .as_object() + .expect("started event object") + .keys() + .map(String::as_str) + .collect(); + started_keys.sort_unstable(); + assert_eq!( + started_keys, + [ + "edge", + "event_name", + "observed_at_unix_ms", + "phase", + "process_boot_id", + "process_elapsed_ms", + "process_started_at_unix_ms", + "schema_version", + "sequence", + "track", + ] + ); + let mut terminal_keys: Vec<_> = values[1] + .as_object() + .expect("terminal event object") + .keys() + .map(String::as_str) + .collect(); + terminal_keys.sort_unstable(); + assert_eq!( + terminal_keys, + [ + "edge", + "event_name", + "observed_at_unix_ms", + "phase", + "phase_elapsed_ms", + "process_boot_id", + "process_elapsed_ms", + "process_started_at_unix_ms", + "reason", + "schema_version", + "sequence", + "status", + "track", + ] + ); + assert_eq!( + LifecycleStatus::ALL.map(LifecycleStatus::as_str), + ["succeeded", "degraded", "failed", "abandoned",] + ); + assert_eq!( + LifecycleReason::ALL.map(LifecycleReason::as_str), + [ + "runtime_build", + "provider_conflict", + "exporter_build", + "config_invalid", + "missing", + "required_invalid", + "bind", + "recorder_conflict", + "owner_dropped", + "panic", + ] + ); + } +} diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d9432589f46..206f0329c0e 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1,5 +1,4 @@ use std::collections::{HashMap, HashSet}; -use std::sync::atomic::Ordering; use std::sync::Arc; use tracing::{error, info, warn}; @@ -18,6 +17,7 @@ use buzz_pubsub::PubSubManager; use buzz_search::SearchService; use buzz_relay::config::{Config, MAX_DRAIN_JITTER_MS}; +use buzz_relay::lifecycle::{BootTracker, LifecycleReason, StartupPhase}; use buzz_relay::metrics as relay_metrics; use buzz_relay::router::{build_health_router, build_router}; use buzz_relay::state::AppState; @@ -35,6 +35,18 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +async fn connect_audit_pool(config: &DbConfig) -> anyhow::Result { + let audit_config = DbConfig { + read_database_url: None, + max_connections: 5, + min_connections: 1, + ..config.clone() + }; + Db::connect_writer_pool(&audit_config) + .await + .map_err(Into::into) +} + fn relay_keypair_from_config(relay_private_key: Option<&str>) -> anyhow::Result { let hex = relay_private_key.ok_or_else(|| { anyhow::anyhow!( @@ -93,15 +105,36 @@ impl EmissionScope { const USAGE_METRICS_LOCK_KEY: i64 = 0x4255_5A5A_4D45_5452; -#[tokio::main] -async fn main() -> anyhow::Result<()> { +fn main() -> anyhow::Result<()> { + let (runtime, boot) = BootTracker::start_before_runtime(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + }) + .map_err(|error| anyhow::anyhow!("failed to build Tokio runtime: {error}"))?; + runtime.block_on(run_relay_main(boot)) +} + +async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { // Install the ring CryptoProvider for rustls. Required before any rustls // TLS connection (rediss:// to ElastiCache, wss://, S3 over TLS): both // aws-lc-rs and ring are compiled in transitively, so rustls can't // auto-select a provider and would panic at first use without this. - rustls::crypto::ring::default_provider() - .install_default() - .expect("failed to install rustls crypto provider"); + let (mut boot, ()) = boot + .run_required( + StartupPhase::CryptoInit, + || { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_provider| ()) + }, + |_error| LifecycleReason::ProviderConflict, + ) + .map_err(|()| { + anyhow::anyhow!( + "failed to install rustls crypto provider: another provider is already installed" + ) + })?; // JSON-only structured logs — simple, machine-parseable, CAKE-compatible. // If OTEL_EXPORTER_OTLP_ENDPOINT is set, also attach an OpenTelemetry tracing @@ -110,6 +143,7 @@ async fn main() -> anyhow::Result<()> { // Build a single shared Resource (service.name=buzz-relay by default, overridable // via OTEL_SERVICE_NAME) for the trace provider so that Datadog can identify // spans under the correct service identity. + let tracing_init = boot.start(StartupPhase::TracingInit); let resource = telemetry::service_resource(); let tracer_init = telemetry::try_init_tracer(resource.clone()); let otel_enabled = matches!(&tracer_init, telemetry::TracerInit::Enabled(_)); @@ -143,17 +177,43 @@ async fn main() -> anyhow::Result<()> { .init(); // Log any exporter-build failure now that the subscriber is installed. - if let telemetry::TracerInit::ExporterBuildFailed(ref e) = tracer_init { - warn!(error = %e, "Failed to build OTLP trace exporter; distributed tracing disabled"); + match &tracer_init { + telemetry::TracerInit::Enabled(_) => tracing_init.succeed(), + // Structured logging is installed regardless of whether optional OTLP + // export is configured, so the phase itself completed successfully. + telemetry::TracerInit::Disabled => tracing_init.succeed(), + telemetry::TracerInit::ExporterBuildFailed(_) => { + tracing_init.degrade(LifecycleReason::ExporterBuild); + boot.mark_degraded(LifecycleReason::ExporterBuild); + // Do not log the raw exporter error: OTLP endpoint URLs can carry + // credentials. The bounded lifecycle reason is sufficient here. + warn!("Failed to build OTLP trace exporter; distributed tracing disabled"); + } } info!("Starting buzz-relay"); - let config = Config::from_env().map_err(|e| { - error!("Invalid configuration: {e}"); - anyhow::anyhow!("Configuration error: {e}") - })?; - let relay_keypair = relay_keypair_from_config(config.relay_private_key.as_deref())?; + let (next_boot, config) = boot + .run_required(StartupPhase::ConfigLoad, Config::from_env, |_error| { + LifecycleReason::ConfigInvalid + }) + .map_err(|error| { + error!("Invalid configuration: {error}"); + anyhow::anyhow!("Configuration error: {error}") + })?; + boot = next_boot; + + let key_failure = if config.relay_private_key.is_some() { + LifecycleReason::RequiredInvalid + } else { + LifecycleReason::Missing + }; + let (next_boot, relay_keypair) = boot.run_required( + StartupPhase::KeyLoad, + || relay_keypair_from_config(config.relay_private_key.as_deref()), + |_error| key_failure, + )?; + boot = next_boot; info!( bind_addr = %config.bind_addr, relay_url = %config.relay_url, @@ -167,7 +227,18 @@ async fn main() -> anyhow::Result<()> { let usage_interval_secs = usage_metrics_interval_secs(); let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); - relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); + let (boot, ()) = boot.run_required( + StartupPhase::MetricsBind, + || relay_metrics::try_install(config.metrics_port, usage_idle_timeout_secs), + |error| match error.failure() { + relay_metrics::MetricsInstallFailure::Bind => LifecycleReason::Bind, + relay_metrics::MetricsInstallFailure::RecorderConflict => { + LifecycleReason::RecorderConflict + } + relay_metrics::MetricsInstallFailure::ExporterBuild => LifecycleReason::ExporterBuild, + }, + )?; + boot.finish(); metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); metrics::gauge!("buzz_push_enabled").set(if config.push_enabled { 1.0 } else { 0.0 }); info!( @@ -183,7 +254,8 @@ async fn main() -> anyhow::Result<()> { max_connections: config.db_pool_size, read_max_connections: config.db_read_pool_size, ..DbConfig::default() - }; + } + .with_session_timeouts_from_env(); let db = Db::new(&db_config).await.map_err(|e| { error!("Failed to connect to Postgres: {e}"); anyhow::anyhow!("DB connection failed: {e}") @@ -290,7 +362,7 @@ async fn main() -> anyhow::Result<()> { ); None } else { - match db.ensure_configured_community(&host).await { + match db.ensure_configured_community_for_bootstrap(&host).await { Ok(record) => { info!(host = %record.host, community = %record.id, "Deployment community ensured"); Some(record.id) @@ -366,10 +438,7 @@ async fn main() -> anyhow::Result<()> { } let audit = if config.audit_enabled { - let audit_pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .min_connections(1) - .connect(&config.database_url) + let audit_pool = connect_audit_pool(&db_config) .await .map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?; info!("Audit service ready"); @@ -554,7 +623,11 @@ async fn main() -> anyhow::Result<()> { // this repairs pre-snapshot communities and any publication that failed // after a membership transaction committed. if config.require_relay_membership { - match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots(&state).await + match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots_with_purpose( + &state, + buzz_relay::handlers::side_effects::Nip43ReconciliationPurpose::Bootstrap, + ) + .await { Ok(count) => info!(count, "NIP-43 membership snapshots reconciled on startup"), Err(error) => { @@ -573,8 +646,9 @@ async fn main() -> anyhow::Result<()> { interval.tick().await; loop { interval.tick().await; - match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots( + match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots_with_purpose( &reconcile_state, + buzz_relay::handlers::side_effects::Nip43ReconciliationPurpose::Maintenance, ) .await { @@ -1032,6 +1106,7 @@ async fn main() -> anyhow::Result<()> { metrics::gauge!("buzz_db_pool_idle").set(db_stats.idle as f64); metrics::gauge!("buzz_db_pool_active").set(active as f64); metrics::gauge!("buzz_db_pool_max").set(db_stats.max as f64); + pool_state.db.refresh_pool_waiter_metrics(); if let Some(read_stats) = pool_state.db.read_pool_stats() { let read_active = read_stats.size.saturating_sub(read_stats.idle); @@ -1302,7 +1377,7 @@ async fn serve( }); let (shutdown_tx, _) = tokio::sync::watch::channel(false); - let shutdown_flag = Arc::clone(&state.shutting_down); + let shutdown_state = Arc::clone(&state); let drain_conn_manager = Arc::clone(&state.conn_manager); let drain_jitter_ms = state.config.drain_jitter_ms; let tx = shutdown_tx.clone(); @@ -1335,7 +1410,7 @@ async fn serve( // 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); + shutdown_state.begin_shutdown(); info!("Shutdown signal received — readiness now returns 503"); // 5s grace: let K8s stop routing new traffic before we close listeners. tokio::time::sleep(std::time::Duration::from_secs(5)).await; @@ -2052,10 +2127,11 @@ mod tests { use uuid::Uuid; use super::{ - buzz_auto_migrate_enabled, dropped_in_memory_keys, idle_timeout_secs, + buzz_auto_migrate_enabled, connect_audit_pool, dropped_in_memory_keys, idle_timeout_secs, refresh_legacy_active_gauge_recency, relay_keypair_from_config, run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; + use buzz_db::DbConfig; use metrics::GaugeFn; use metrics_util::{ debugging::DebugValue, @@ -2087,6 +2163,73 @@ mod tests { assert!(tick_count.load(std::sync::atomic::Ordering::Relaxed) <= 1); } + async fn audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits() { + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); + let pool = connect_audit_pool(&DbConfig { + database_url, + max_connections: 2, + min_connections: 0, + lock_timeout_ms: 500, + idle_txn_timeout_ms: 60_000, + statement_timeout_ms: 0, + ..DbConfig::default() + }) + .await + .expect("connect audit writer pool"); + + let (lock, idle, statement): (String, String, String) = sqlx::query_as( + "SELECT current_setting('lock_timeout'), \ + current_setting('idle_in_transaction_session_timeout'), \ + current_setting('statement_timeout')", + ) + .fetch_one(&pool) + .await + .expect("read effective audit writer GUCs"); + assert_eq!(lock, "500ms"); + assert_eq!(idle, "1min"); + assert_eq!(statement, "0"); + + let lock_key = i64::from_be_bytes( + Uuid::new_v4().as_bytes()[..8] + .try_into() + .expect("eight UUID bytes"), + ); + let mut holder = pool.acquire().await.expect("audit lock holder"); + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("hold audit advisory lock"); + + let started = std::time::Instant::now(); + let mut waiter = pool.acquire().await.expect("audit lock waiter"); + let error = sqlx::query("SELECT pg_advisory_lock($1)") + .bind(lock_key) + .execute(&mut *waiter) + .await + .expect_err("audit advisory-lock waiter must time out"); + let code = match &error { + sqlx::Error::Database(db_error) => db_error.code().map(|code| code.to_string()), + other => panic!("expected database error, got {other:?}"), + }; + assert_eq!(code.as_deref(), Some("55P03")); + assert!(started.elapsed() < Duration::from_secs(5)); + + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("release audit advisory lock"); + } + + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits() { + super::audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits().await; + } + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 16e521a44ee..f71894116c3 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -21,7 +21,7 @@ use axum::{ middleware::Next, response::Response, }; -use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; +use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder}; use metrics_util::MetricKindMask; /// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`. @@ -32,6 +32,17 @@ const LATENCY_BUCKETS_MS: [f64; 11] = [ /// Seconds-scale buckets for internal processing histograms (event, search, audit). const DURATION_BUCKETS_S: [f64; 10] = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0]; +/// Readiness buckets concentrate resolution near the two-second failure budget. +const READINESS_DURATION_BUCKETS_S: [f64; 15] = [ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, +]; + +/// Pool checkout buckets: dense around normal sub-100ms waits, with explicit +/// coverage of the reader's 150ms and writer's default three-second budgets. +const DB_POOL_ACQUIRE_DURATION_BUCKETS_S: [f64; 9] = + [0.001, 0.005, 0.01, 0.025, 0.05, 0.15, 0.5, 1.0, 3.0]; +const DB_POOL_ACQUIRE_DURATION_UNIT: metrics::Unit = metrics::Unit::Seconds; + /// Seconds-scale buckets for Git hydration and pack streams. const GIT_DURATION_BUCKETS_S: [f64; 13] = [ 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, @@ -56,16 +67,8 @@ const GIT_PACK_BUCKETS: [f64; 9] = [0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 1 /// Integer-count buckets for fan-out recipient histograms. const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, 1000.0]; -/// Install the global metrics recorder and spawn the Prometheus HTTP exporter. -/// -/// `build()` returns the recorder + exporter future and internally spawns -/// the upkeep task, so no separate upkeep call is needed. -/// -/// Must be called from within a Tokio runtime. -/// Panics if a recorder is already installed or the port is in use. -pub fn install(port: u16, gauge_idle_timeout_secs: u64) { - let (recorder, exporter) = PrometheusBuilder::new() - .with_http_listener(([0, 0, 0, 0], port)) +fn configured_prometheus_builder(gauge_idle_timeout_secs: u64) -> PrometheusBuilder { + PrometheusBuilder::new() // Remove gauge series that the relay intentionally stops emitting. .idle_timeout( MetricKindMask::GAUGE, @@ -102,6 +105,16 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { &GIT_DURATION_BUCKETS_S, ) .expect("valid git compaction duration bucket boundaries") + .set_buckets_for_metric( + Matcher::Full("buzz_readiness_check_duration_seconds".to_owned()), + &READINESS_DURATION_BUCKETS_S, + ) + .expect("valid readiness duration bucket boundaries") + .set_buckets_for_metric( + Matcher::Full("buzz_db_pool_acquire_duration_seconds".to_owned()), + &DB_POOL_ACQUIRE_DURATION_BUCKETS_S, + ) + .expect("valid DB pool acquisition duration bucket boundaries") .set_buckets_for_metric( Matcher::Full("buzz_git_hydrate_bytes".to_owned()), &GIT_BYTES_BUCKETS, @@ -139,11 +152,119 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { &FANOUT_BUCKETS, ) .expect("valid fanout bucket boundaries") +} + +/// A bounded class of metrics installation failure. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MetricsInstallFailure { + /// The Prometheus listener could not bind. + Bind, + /// Another component already installed a global recorder. + RecorderConflict, + /// The exporter could not be built for another reason. + ExporterBuild, +} + +/// An error returned while installing Prometheus metrics. +#[derive(Debug, thiserror::Error)] +pub enum MetricsInstallError { + /// Prometheus exporter construction failed. + #[error("failed to build Prometheus exporter: {0}")] + Build(#[source] BuildError), + /// Another component already installed the process-global recorder. + #[error("the global metrics recorder is already installed")] + RecorderConflict, +} + +impl MetricsInstallError { + /// Return the secret-safe lifecycle classification. + pub const fn failure(&self) -> MetricsInstallFailure { + match self { + Self::Build(BuildError::FailedToCreateHTTPListener(_)) => MetricsInstallFailure::Bind, + Self::Build(_) => MetricsInstallFailure::ExporterBuild, + Self::RecorderConflict => MetricsInstallFailure::RecorderConflict, + } + } +} + +/// Try to install the global metrics recorder and spawn the Prometheus HTTP exporter. +/// +/// `build()` returns the recorder + exporter future and internally spawns +/// the upkeep task, so no separate upkeep call is needed. +/// +/// Must be called from within a Tokio runtime. +/// Listener and global-recorder failures are returned rather than panicking. +/// A later exporter exit remains detached from relay service; external scrape +/// coverage is authoritative for exporter availability. +pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), MetricsInstallError> { + let (recorder, exporter) = configured_prometheus_builder(gauge_idle_timeout_secs) + .with_http_listener(([0, 0, 0, 0], port)) .build() - .expect("metrics exporter must build exactly once"); + .map_err(MetricsInstallError::Build)?; - metrics::set_global_recorder(recorder).expect("global recorder must be set exactly once"); + metrics::set_global_recorder(recorder) + .map_err(|_error| MetricsInstallError::RecorderConflict)?; + describe_readiness_metrics(); + describe_db_pool_metrics(); tokio::spawn(exporter); + Ok(()) +} + +/// Install the global metrics recorder and spawn the Prometheus HTTP exporter. +/// +/// This compatibility entry point preserves the original panic-on-failure API. +/// New startup code should use [`try_install`] to report typed failures. +pub fn install(port: u16, gauge_idle_timeout_secs: u64) { + try_install(port, gauge_idle_timeout_secs) + .unwrap_or_else(|error| panic!("metrics exporter must install exactly once: {error}")); +} + +/// Register the frozen readiness metric descriptions with the active recorder. +pub(crate) fn describe_readiness_metrics() { + metrics::describe_counter!( + "buzz_readiness_checks_total", + "Kubernetes health-listener readiness probes by terminal bounded reason" + ); + metrics::describe_counter!( + "buzz_readiness_dependency_checks_total", + "Completed readiness dependency attempts by dependency and bounded outcome" + ); + metrics::describe_histogram!( + "buzz_readiness_check_duration_seconds", + metrics::Unit::Seconds, + "Completed readiness check duration without outcome label multiplication" + ); + metrics::describe_gauge!( + "buzz_readiness_state", + "Latest publishable readiness state by check, where 1 is ready and 0 is not ready" + ); +} + +/// Register the frozen operation-aware pool-acquisition contract. +pub(crate) fn describe_db_pool_metrics() { + metrics::describe_histogram!( + "buzz_db_pool_acquire_duration_seconds", + DB_POOL_ACQUIRE_DURATION_UNIT, + "Database pool checkout duration by valid pool role and operation" + ); + metrics::describe_counter!( + "buzz_db_pool_acquire_attempts_total", + "Database pool checkout terminals by valid pool role, operation, and outcome" + ); + metrics::describe_gauge!( + "buzz_db_pool_waiters", + "Current tracked-operation database pool checkout attempts in progress by valid pool role and operation" + ); +} + +#[cfg(test)] +pub(crate) fn readiness_test_recorder() -> ( + metrics_exporter_prometheus::PrometheusRecorder, + metrics_exporter_prometheus::PrometheusHandle, +) { + let recorder = configured_prometheus_builder(300).build_recorder(); + let handle = recorder.handle(); + (recorder, handle) } /// Axum middleware that records CAKE framework HTTP metrics. @@ -205,3 +326,139 @@ pub async fn track_metrics(req: Request, next: Next) -> Response { response } +#[cfg(test)] +mod contract_tests { + use std::collections::BTreeSet; + + const OUTCOMES: [&str; 4] = ["success", "timeout", "error", "cancelled"]; + + fn label_keys(line: &str) -> BTreeSet<&str> { + line.split_once('{') + .and_then(|(_, rest)| rest.split_once('}')) + .map(|(labels, _)| { + labels + .split(',') + .filter_map(|label| label.split_once('=').map(|(key, _)| key)) + .collect() + }) + .unwrap_or_default() + } + + #[test] + fn production_builder_exports_frozen_db_pool_contract_and_187_series_budget() { + let (recorder, handle) = super::readiness_test_recorder(); + metrics::with_local_recorder(&recorder, || { + super::describe_db_pool_metrics(); + for (pool_role, operation) in buzz_db::DB_POOL_ACQUIRE_VALID_PAIRS { + metrics::histogram!( + "buzz_db_pool_acquire_duration_seconds", + "pool_role" => pool_role, + "operation" => operation, + ) + .record(0.02); + metrics::gauge!( + "buzz_db_pool_waiters", + "pool_role" => pool_role, + "operation" => operation, + ) + .set(0.0); + for outcome in OUTCOMES { + metrics::counter!( + "buzz_db_pool_acquire_attempts_total", + "pool_role" => pool_role, + "operation" => operation, + "outcome" => outcome, + ) + .increment(1); + } + } + }); + + let scrape = handle.render(); + assert!(scrape.contains("# TYPE buzz_db_pool_acquire_duration_seconds histogram")); + assert!(scrape.contains("# TYPE buzz_db_pool_acquire_attempts_total counter")); + assert!(scrape.contains("# TYPE buzz_db_pool_waiters gauge")); + assert!(scrape.contains("# HELP buzz_db_pool_acquire_duration_seconds Database pool checkout duration by valid pool role and operation")); + assert!(scrape.contains("# HELP buzz_db_pool_acquire_attempts_total Database pool checkout terminals by valid pool role, operation, and outcome")); + assert!(scrape.contains("# HELP buzz_db_pool_waiters Current tracked-operation database pool checkout attempts in progress by valid pool role and operation")); + assert_eq!(super::DB_POOL_ACQUIRE_DURATION_UNIT, metrics::Unit::Seconds); + let readiness_buckets = scrape + .lines() + .filter(|line| { + line.starts_with("buzz_db_pool_acquire_duration_seconds_bucket{") + && line.contains("pool_role=\"writer\"") + && line.contains("operation=\"readiness\"") + }) + .map(|line| { + line.split(",le=\"") + .nth(1) + .and_then(|rest| rest.split_once('"').map(|(bucket, _)| bucket)) + .expect("duration bucket carries le label") + }) + .collect::>(); + assert_eq!( + readiness_buckets, + ["0.001", "0.005", "0.01", "0.025", "0.05", "0.15", "0.5", "1", "3", "+Inf",], + "duration bucket contract drifted:\n{scrape}" + ); + + let raw_series = scrape + .lines() + .filter(|line| { + line.starts_with("buzz_db_pool_acquire_duration_seconds") + || line.starts_with("buzz_db_pool_acquire_attempts_total") + || line.starts_with("buzz_db_pool_waiters{") + }) + .collect::>(); + assert_eq!( + raw_series.len(), + buzz_db::DB_POOL_ACQUIRE_RAW_SERIES_PER_POD, + "unexpected raw scrape:\n{scrape}" + ); + + for line in raw_series { + let keys = label_keys(line); + if line.starts_with("buzz_db_pool_acquire_duration_seconds_bucket") { + assert_eq!(keys, BTreeSet::from(["le", "operation", "pool_role"])); + } else if line.starts_with("buzz_db_pool_acquire_duration_seconds") { + assert_eq!(keys, BTreeSet::from(["operation", "pool_role"])); + } else if line.starts_with("buzz_db_pool_acquire_attempts_total") { + assert_eq!(keys, BTreeSet::from(["operation", "outcome", "pool_role"])); + } else { + assert_eq!(keys, BTreeSet::from(["operation", "pool_role"])); + } + assert!(!line.contains("operation=\"other\"")); + assert!(!line.contains("result=")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn occupied_listener_is_classified_as_bind() { + let listener = std::net::TcpListener::bind(("0.0.0.0", 0)).expect("bind occupied port"); + let port = listener.local_addr().expect("occupied address").port(); + let error = try_install(port, 300).expect_err("occupied listener must fail"); + assert_eq!(error.failure(), MetricsInstallFailure::Bind); + } + + #[tokio::test] + async fn recorder_conflict_is_typed_in_an_isolated_process() { + const CHILD_ENV: &str = "BUZZ_TEST_METRICS_RECORDER_CONFLICT"; + if std::env::var_os(CHILD_ENV).is_some() { + let recorder = configured_prometheus_builder(300).build_recorder(); + metrics::set_global_recorder(recorder).expect("install first recorder"); + let error = try_install(0, 300).expect_err("second recorder must fail"); + assert_eq!(error.failure(), MetricsInstallFailure::RecorderConflict); + return; + } + + crate::test_support::run_exact_test_child( + "metrics::tests::recorder_conflict_is_typed_in_an_isolated_process", + CHILD_ENV, + ); + } +} diff --git a/crates/buzz-relay/src/protocol.rs b/crates/buzz-relay/src/protocol.rs index 89b4810fd52..5832a72a879 100644 --- a/crates/buzz-relay/src/protocol.rs +++ b/crates/buzz-relay/src/protocol.rs @@ -22,6 +22,8 @@ pub enum ClientMessage { sub_id: String, /// The filters that determine which events are delivered. filters: Vec, + /// Optional per-filter composite cursor tiebreaks from raw extension fields. + before_ids: Vec>>, }, /// A CLOSE message cancelling an active subscription. Close(String), @@ -103,7 +105,40 @@ impl ClientMessage { .map_err(|e| RelayError::InvalidMessage(format!("invalid filter: {e}"))) }) .collect::>>()?; - Ok(ClientMessage::Req { sub_id, filters }) + let before_ids = filter_values + .iter() + .map(|value| { + let Some(raw) = value.get("before_id") else { + return Ok(None); + }; + if value.get("until").is_none() { + return Err(RelayError::InvalidMessage( + "before_id requires until to be set".to_string(), + )); + } + let Some(hex) = raw.as_str() else { + return Err(RelayError::InvalidMessage( + "before_id must be a 64-char hex event id".to_string(), + )); + }; + let bytes = hex::decode(hex).map_err(|_| { + RelayError::InvalidMessage( + "before_id must be a 64-char hex event id".to_string(), + ) + })?; + if bytes.len() != 32 { + return Err(RelayError::InvalidMessage( + "before_id must be a 64-char hex event id".to_string(), + )); + } + Ok(Some(bytes)) + }) + .collect::>>()?; + Ok(ClientMessage::Req { + sub_id, + filters, + before_ids, + }) } "COUNT" => { if arr.len() < 2 { @@ -252,7 +287,9 @@ mod tests { &serde_json::json!(["REQ", "sub1", serde_json::to_value(&filter).unwrap()]) .to_string(), Box::new(|m| match m { - ClientMessage::Req { sub_id, filters } => { + ClientMessage::Req { + sub_id, filters, .. + } => { assert_eq!(sub_id, "sub1"); assert_eq!(filters.len(), 1); } @@ -294,7 +331,9 @@ mod tests { ]) .to_string(); match ClientMessage::parse(&raw).unwrap() { - ClientMessage::Req { sub_id, filters } => { + ClientMessage::Req { + sub_id, filters, .. + } => { assert_eq!(sub_id, "sub2"); assert_eq!(filters.len(), 2); } @@ -302,6 +341,49 @@ mod tests { } } + #[test] + fn parse_req_composite_cursor_preserves_filter_alignment() { + let raw = serde_json::json!([ + "REQ", + "sub-cursor", + { "kinds": [9] }, + { + "kinds": [48100], + "until": 1_000, + "before_id": "ab".repeat(32), + } + ]) + .to_string(); + + match ClientMessage::parse(&raw).unwrap() { + ClientMessage::Req { + filters, + before_ids, + .. + } => { + assert_eq!(filters.len(), 2); + assert_eq!(before_ids, vec![None, Some(vec![0xab; 32])]); + } + _ => panic!("expected Req"), + } + } + + #[test] + fn parse_req_composite_cursor_rejects_invalid_pairs() { + for raw in [ + serde_json::json!(["REQ", "sub", { "before_id": "ab".repeat(32) }]), + serde_json::json!(["REQ", "sub", { + "until": 1_000, + "before_id": "short", + }]), + ] { + assert!(matches!( + ClientMessage::parse(&raw.to_string()), + Err(RelayError::InvalidMessage(_)) + )); + } + } + #[test] fn parse_invalid_messages() { let cases = [ diff --git a/crates/buzz-relay/src/readiness.rs b/crates/buzz-relay/src/readiness.rs new file mode 100644 index 00000000000..79a1a985707 --- /dev/null +++ b/crates/buzz-relay/src/readiness.rs @@ -0,0 +1,855 @@ +//! Readiness dependency evaluation and ordered metrics publication. +//! +//! [`ReadinessCoordinator`] is process-owned. Its mutex is the linearization +//! point shared by health-probe commits and terminal shutdown, so an older +//! evaluation can never overwrite newer gauges or publish ready after shutdown. + +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; + +use buzz_db::{Db, DbError, DbReadinessOutcome}; +use tokio::time::Instant; + +const READINESS_TIMEOUT: Duration = Duration::from_secs(2); + +/// Closed label set exported by `buzz_readiness_checks_total{reason}`. +#[cfg(test)] +pub(crate) const READINESS_REASON_LABELS: [&str; 12] = [ + "ready", + "shutting_down", + "postgres_pool_timeout", + "postgres_pool_error", + "postgres_query_timeout", + "postgres_query_error", + "redis_pool_timeout", + "redis_pool_error", + "deletion_catalog_timeout", + "deletion_catalog_error", + "overall_timeout", + "multiple_dependencies_failed", +]; + +/// Maximum raw Prometheus series emitted by readiness for one pod. +/// +/// - 12 overall reasons +/// - 11 valid dependency/outcome pairs (Postgres 5, Redis 3, catalog 3) +/// - 4 histograms x (15 configured buckets + `+Inf` + count + sum) = 72 +/// - 4 current-state gauges +#[cfg(test)] +pub(crate) const READINESS_RAW_SERIES_PER_POD: usize = 12 + 11 + (4 * 18) + 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PostgresOutcome { + Success, + PoolTimeout, + PoolError, + QueryTimeout, + QueryError, +} + +impl PostgresOutcome { + fn label(self) -> &'static str { + match self { + Self::Success => "success", + Self::PoolTimeout => "pool_timeout", + Self::PoolError => "pool_error", + Self::QueryTimeout => "operation_timeout", + Self::QueryError => "operation_error", + } + } + + fn is_success(self) -> bool { + self == Self::Success + } + + fn is_timeout(self) -> bool { + matches!(self, Self::PoolTimeout | Self::QueryTimeout) + } +} + +impl From for PostgresOutcome { + fn from(outcome: DbReadinessOutcome) -> Self { + match outcome { + DbReadinessOutcome::Success => Self::Success, + DbReadinessOutcome::PoolTimeout => Self::PoolTimeout, + DbReadinessOutcome::PoolError => Self::PoolError, + DbReadinessOutcome::QueryTimeout => Self::QueryTimeout, + DbReadinessOutcome::QueryError => Self::QueryError, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RedisOutcome { + Success, + PoolTimeout, + PoolError, +} + +impl RedisOutcome { + fn label(self) -> &'static str { + match self { + Self::Success => "success", + Self::PoolTimeout => "pool_timeout", + Self::PoolError => "pool_error", + } + } + + fn is_success(self) -> bool { + self == Self::Success + } + + fn is_timeout(self) -> bool { + self == Self::PoolTimeout + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeletionCatalogOutcome { + Success, + OperationTimeout, + OperationError, +} + +impl DeletionCatalogOutcome { + fn label(self) -> &'static str { + match self { + Self::Success => "success", + Self::OperationTimeout => "operation_timeout", + Self::OperationError => "operation_error", + } + } + + fn is_success(self) -> bool { + self == Self::Success + } + + fn is_timeout(self) -> bool { + self == Self::OperationTimeout + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReadinessReason { + Ready, + ShuttingDown, + PostgresPoolTimeout, + PostgresPoolError, + PostgresQueryTimeout, + PostgresQueryError, + RedisPoolTimeout, + RedisPoolError, + DeletionCatalogTimeout, + DeletionCatalogError, + OverallTimeout, + MultipleDependenciesFailed, +} + +impl ReadinessReason { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::ShuttingDown => "shutting_down", + Self::PostgresPoolTimeout => "postgres_pool_timeout", + Self::PostgresPoolError => "postgres_pool_error", + Self::PostgresQueryTimeout => "postgres_query_timeout", + Self::PostgresQueryError => "postgres_query_error", + Self::RedisPoolTimeout => "redis_pool_timeout", + Self::RedisPoolError => "redis_pool_error", + Self::DeletionCatalogTimeout => "deletion_catalog_timeout", + Self::DeletionCatalogError => "deletion_catalog_error", + Self::OverallTimeout => "overall_timeout", + Self::MultipleDependenciesFailed => "multiple_dependencies_failed", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct TimedOutcome { + outcome: O, + duration: Duration, +} + +impl TimedOutcome { + #[cfg(test)] + pub(crate) fn new(outcome: O, duration: Duration) -> Self { + Self { outcome, duration } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ReadinessEvaluation { + postgres: Option>, + redis: Option>, + deletion_catalog: Option>, + pub(crate) reason: ReadinessReason, + total_duration: Duration, +} + +impl ReadinessEvaluation { + pub(crate) fn shutting_down() -> Self { + Self { + postgres: None, + redis: None, + deletion_catalog: None, + reason: ReadinessReason::ShuttingDown, + total_duration: Duration::ZERO, + } + } + + #[cfg(test)] + pub(crate) fn from_results( + postgres: TimedOutcome, + redis: TimedOutcome, + deletion_catalog: TimedOutcome, + total_duration: Duration, + ) -> Self { + Self::for_dependencies(postgres, redis, deletion_catalog, total_duration) + } + + fn for_dependencies( + postgres: TimedOutcome, + redis: TimedOutcome, + deletion_catalog: TimedOutcome, + total_duration: Duration, + ) -> Self { + let reason = final_reason(postgres.outcome, redis.outcome, deletion_catalog.outcome); + Self { + postgres: Some(postgres), + redis: Some(redis), + deletion_catalog: Some(deletion_catalog), + reason, + total_duration, + } + } + + pub(crate) fn is_ready(self) -> bool { + self.reason == ReadinessReason::Ready + } + + pub(crate) fn postgres_ready(self) -> bool { + self.postgres + .is_some_and(|result| result.outcome.is_success()) + } + + pub(crate) fn redis_ready(self) -> bool { + self.redis.is_some_and(|result| result.outcome.is_success()) + } + + pub(crate) fn deletion_catalog_ready(self) -> bool { + self.deletion_catalog + .is_some_and(|result| result.outcome.is_success()) + } + + fn dependencies_ran(self) -> bool { + self.postgres.is_some() || self.redis.is_some() || self.deletion_catalog.is_some() + } +} + +fn final_reason( + postgres: PostgresOutcome, + redis: RedisOutcome, + deletion_catalog: DeletionCatalogOutcome, +) -> ReadinessReason { + let failure_count = usize::from(!postgres.is_success()) + + usize::from(!redis.is_success()) + + usize::from(!deletion_catalog.is_success()); + + if failure_count == 0 { + return ReadinessReason::Ready; + } + if failure_count > 1 { + let all_failures_are_timeouts = (postgres.is_success() || postgres.is_timeout()) + && (redis.is_success() || redis.is_timeout()) + && (deletion_catalog.is_success() || deletion_catalog.is_timeout()); + return if all_failures_are_timeouts { + ReadinessReason::OverallTimeout + } else { + ReadinessReason::MultipleDependenciesFailed + }; + } + + match postgres { + PostgresOutcome::PoolTimeout => ReadinessReason::PostgresPoolTimeout, + PostgresOutcome::PoolError => ReadinessReason::PostgresPoolError, + PostgresOutcome::QueryTimeout => ReadinessReason::PostgresQueryTimeout, + PostgresOutcome::QueryError => ReadinessReason::PostgresQueryError, + PostgresOutcome::Success => match redis { + RedisOutcome::PoolTimeout => ReadinessReason::RedisPoolTimeout, + RedisOutcome::PoolError => ReadinessReason::RedisPoolError, + RedisOutcome::Success => match deletion_catalog { + DeletionCatalogOutcome::OperationTimeout => ReadinessReason::DeletionCatalogTimeout, + DeletionCatalogOutcome::OperationError => ReadinessReason::DeletionCatalogError, + DeletionCatalogOutcome::Success => ReadinessReason::Ready, + }, + }, + } +} + +async fn timed(future: F) -> TimedOutcome +where + F: Future, +{ + let started_at = Instant::now(); + let outcome = future.await; + TimedOutcome { + outcome, + duration: started_at.elapsed(), + } +} + +async fn evaluate_dependencies( + postgres: P, + redis: R, + deletion_catalog: D, +) -> ReadinessEvaluation +where + P: Future, + R: Future, + D: Future, +{ + let started_at = Instant::now(); + let (postgres, redis, deletion_catalog) = + tokio::join!(timed(postgres), timed(redis), timed(deletion_catalog),); + ReadinessEvaluation::for_dependencies(postgres, redis, deletion_catalog, started_at.elapsed()) +} + +async fn redis_check(pool: &deadpool_redis::Pool, deadline: Instant) -> RedisOutcome { + match tokio::time::timeout_at(deadline, pool.get()).await { + Err(_) => RedisOutcome::PoolTimeout, + Ok(Err(error)) => { + tracing::debug!(error = %error, "Redis readiness pool acquisition failed"); + RedisOutcome::PoolError + } + Ok(Ok(_connection)) => RedisOutcome::Success, + } +} + +async fn deletion_catalog_check(db: &Db, deadline: Instant) -> DeletionCatalogOutcome { + classify_deletion_catalog_result( + db.validate_deletion_serving_catalog_for_readiness(deadline) + .await, + ) +} + +fn classify_deletion_catalog_result(result: buzz_db::Result<()>) -> DeletionCatalogOutcome { + match result { + Err(DbError::Sqlx(sqlx::Error::PoolTimedOut)) => DeletionCatalogOutcome::OperationTimeout, + Err(error) => { + tracing::debug!(error = %error, "Deletion catalog readiness validation failed"); + DeletionCatalogOutcome::OperationError + } + Ok(()) => DeletionCatalogOutcome::Success, + } +} + +#[async_trait::async_trait] +pub(crate) trait ReadinessEvaluator: Send + Sync { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation; +} + +struct ProductionReadinessEvaluator; + +#[async_trait::async_trait] +impl ReadinessEvaluator for ProductionReadinessEvaluator { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation { + let deadline = Instant::now() + READINESS_TIMEOUT; + evaluate_dependencies( + async { db.readiness_check(deadline).await.into() }, + redis_check(redis_pool, deadline), + deletion_catalog_check(db, deadline), + ) + .await + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ProbeTicket { + generation: u64, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum ProbeStart { + Evaluate(ProbeTicket), + ShuttingDown, +} + +#[derive(Debug, Default)] +struct PublicationState { + next_generation: u64, + latest_published_generation: u64, + shutdown_generation: Option, +} + +/// Serializes readiness result publication with terminal process shutdown. +pub(crate) struct ReadinessCoordinator { + state: Mutex, + evaluator: Arc, +} + +impl Default for ReadinessCoordinator { + fn default() -> Self { + Self { + state: Mutex::new(PublicationState::default()), + evaluator: Arc::new(ProductionReadinessEvaluator), + } + } +} + +impl ReadinessCoordinator { + #[cfg(test)] + pub(crate) fn with_evaluator(evaluator: Arc) -> Self { + Self { + state: Mutex::new(PublicationState::default()), + evaluator, + } + } + + fn lock_state(&self) -> MutexGuard<'_, PublicationState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) async fn evaluate( + &self, + db: &Db, + redis_pool: &deadpool_redis::Pool, + ) -> ReadinessEvaluation { + self.evaluator.evaluate(db, redis_pool).await + } + + /// Allocates a health-probe generation or records a truthful shutdown fast path. + pub(crate) fn begin_probe(&self) -> ProbeStart { + let mut state = self.lock_state(); + if state.shutdown_generation.is_some() { + let evaluation = ReadinessEvaluation::shutting_down(); + record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); + record_overall_state(false); + return ProbeStart::ShuttingDown; + } + + state.next_generation = state.next_generation.saturating_add(1); + ProbeStart::Evaluate(ProbeTicket { + generation: state.next_generation, + }) + } + + /// Commits one completed health probe through the shared publication fence. + pub(crate) fn finish_probe( + &self, + ticket: ProbeTicket, + evaluation: ReadinessEvaluation, + ) -> ReadinessEvaluation { + let mut state = self.lock_state(); + if state.shutdown_generation.is_some() { + record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); + return ReadinessEvaluation::shutting_down(); + } + + record_attempt_metrics(&evaluation, evaluation.reason); + if ticket.generation > state.latest_published_generation { + record_current_state(&evaluation); + state.latest_published_generation = ticket.generation; + } + evaluation + } + + /// Returns whether a compatibility/public readiness evaluation may start. + pub(crate) fn public_evaluation_allowed(&self) -> bool { + self.lock_state().shutdown_generation.is_none() + } + + /// Makes shutdown dominate a public request that was already in flight. + pub(crate) fn finish_public_evaluation( + &self, + evaluation: ReadinessEvaluation, + ) -> ReadinessEvaluation { + if self.lock_state().shutdown_generation.is_some() { + ReadinessEvaluation::shutting_down() + } else { + evaluation + } + } + + /// Commits terminal shutdown and immediately publishes overall not-ready. + pub(crate) fn begin_shutdown(&self) { + let mut state = self.lock_state(); + if state.shutdown_generation.is_none() { + let generation = state.next_generation.saturating_add(1); + state.shutdown_generation = Some(generation); + record_overall_state(false); + } + } +} + +fn record_attempt_metrics(evaluation: &ReadinessEvaluation, reason: ReadinessReason) { + metrics::counter!( + "buzz_readiness_checks_total", + "reason" => reason.label(), + ) + .increment(1); + + if !evaluation.dependencies_ran() { + return; + } + + metrics::histogram!( + "buzz_readiness_check_duration_seconds", + "check" => "overall", + ) + .record(evaluation.total_duration.as_secs_f64()); + + if let Some(result) = evaluation.postgres { + record_dependency_attempt("postgres", result.outcome.label(), result.duration); + } + if let Some(result) = evaluation.redis { + record_dependency_attempt("redis", result.outcome.label(), result.duration); + } + if let Some(result) = evaluation.deletion_catalog { + record_dependency_attempt("deletion_catalog", result.outcome.label(), result.duration); + } +} + +fn record_dependency_attempt(dependency: &'static str, outcome: &'static str, duration: Duration) { + metrics::counter!( + "buzz_readiness_dependency_checks_total", + "dependency" => dependency, + "outcome" => outcome, + ) + .increment(1); + metrics::histogram!( + "buzz_readiness_check_duration_seconds", + "check" => dependency, + ) + .record(duration.as_secs_f64()); +} + +fn record_current_state(evaluation: &ReadinessEvaluation) { + record_overall_state(evaluation.is_ready()); + if let Some(result) = evaluation.postgres { + record_dependency_state("postgres", result.outcome.is_success()); + } + if let Some(result) = evaluation.redis { + record_dependency_state("redis", result.outcome.is_success()); + } + if let Some(result) = evaluation.deletion_catalog { + record_dependency_state("deletion_catalog", result.outcome.is_success()); + } +} + +fn record_overall_state(ready: bool) { + metrics::gauge!("buzz_readiness_state", "check" => "overall").set(if ready { + 1.0 + } else { + 0.0 + }); +} + +fn record_dependency_state(dependency: &'static str, ready: bool) { + metrics::gauge!("buzz_readiness_state", "check" => dependency).set(if ready { + 1.0 + } else { + 0.0 + }); +} + +#[cfg(test)] +mod tests { + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use metrics_util::CompositeKey; + + use super::*; + + fn ready_evaluation() -> ReadinessEvaluation { + ReadinessEvaluation::from_results( + TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), + TimedOutcome::new(RedisOutcome::Success, Duration::from_millis(10)), + TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), + Duration::from_millis(35), + ) + } + + fn redis_failure_evaluation() -> ReadinessEvaluation { + ReadinessEvaluation::from_results( + TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), + TimedOutcome::new(RedisOutcome::PoolTimeout, Duration::from_secs(2)), + TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), + Duration::from_secs(2), + ) + } + + fn exact_metric<'a>( + snapshot: &'a [( + CompositeKey, + Option, + Option, + DebugValue, + )], + name: &str, + labels: &[(&str, &str)], + ) -> Option<&'a DebugValue> { + snapshot.iter().find_map(|(key, _, _, value)| { + let actual = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + (key.key().name() == name + && actual.len() == labels.len() + && labels.iter().all(|expected| actual.contains(expected))) + .then_some(value) + }) + } + + fn gauge_value( + snapshot: &[( + CompositeKey, + Option, + Option, + DebugValue, + )], + check: &str, + ) -> f64 { + let value = exact_metric(snapshot, "buzz_readiness_state", &[("check", check)]) + .expect("readiness gauge"); + let DebugValue::Gauge(value) = value else { + panic!("readiness state must be a gauge"); + }; + value.into_inner() + } + + #[tokio::test(start_paused = true)] + async fn evaluation_preserves_a_completed_check_when_another_times_out() { + let evaluation = evaluate_dependencies( + async { + tokio::time::sleep(Duration::from_millis(35)).await; + PostgresOutcome::Success + }, + async { + tokio::time::sleep(Duration::from_secs(2)).await; + RedisOutcome::PoolTimeout + }, + async { + tokio::time::sleep(Duration::from_millis(10)).await; + DeletionCatalogOutcome::Success + }, + ) + .await; + + assert_eq!(evaluation.reason, ReadinessReason::RedisPoolTimeout); + assert_eq!( + evaluation.postgres.map(|result| result.duration), + Some(Duration::from_millis(35)) + ); + assert_eq!( + evaluation.redis.map(|result| result.duration), + Some(Duration::from_secs(2)) + ); + } + + #[test] + fn simultaneous_dependency_timeouts_are_an_overall_timeout() { + assert_eq!( + final_reason( + PostgresOutcome::PoolTimeout, + RedisOutcome::PoolTimeout, + DeletionCatalogOutcome::Success, + ), + ReadinessReason::OverallTimeout + ); + } + + #[test] + fn dependency_types_expose_only_valid_outcome_pairs() { + assert_eq!( + [ + PostgresOutcome::Success, + PostgresOutcome::PoolTimeout, + PostgresOutcome::PoolError, + PostgresOutcome::QueryTimeout, + PostgresOutcome::QueryError, + ] + .map(PostgresOutcome::label), + [ + "success", + "pool_timeout", + "pool_error", + "operation_timeout", + "operation_error", + ] + ); + assert_eq!( + [ + RedisOutcome::Success, + RedisOutcome::PoolTimeout, + RedisOutcome::PoolError, + ] + .map(RedisOutcome::label), + ["success", "pool_timeout", "pool_error"] + ); + assert_eq!( + [ + DeletionCatalogOutcome::Success, + DeletionCatalogOutcome::OperationTimeout, + DeletionCatalogOutcome::OperationError, + ] + .map(DeletionCatalogOutcome::label), + ["success", "operation_timeout", "operation_error"] + ); + assert_eq!(READINESS_RAW_SERIES_PER_POD, 99); + } + + #[test] + fn deletion_catalog_deadline_is_a_timeout_not_an_operation_error() { + assert_eq!( + classify_deletion_catalog_result(Err(DbError::Sqlx(sqlx::Error::PoolTimedOut))), + DeletionCatalogOutcome::OperationTimeout + ); + assert_eq!( + classify_deletion_catalog_result(Err(DbError::InvalidData("catalog".into()))), + DeletionCatalogOutcome::OperationError + ); + } + + #[test] + fn slow_older_failure_cannot_overwrite_newer_success_gauges() { + let coordinator = ReadinessCoordinator::default(); + let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { + panic!("serving probe A"); + }; + let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { + panic!("serving probe B"); + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + coordinator.finish_probe(fast_b, ready_evaluation()); + coordinator.finish_probe(slow_a, redis_failure_evaluation()); + }); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(gauge_value(&snapshot, "overall"), 1.0); + assert_eq!(gauge_value(&snapshot, "redis"), 1.0); + assert!(matches!( + exact_metric( + &snapshot, + "buzz_readiness_checks_total", + &[("reason", "ready")] + ), + Some(DebugValue::Counter(1)) + )); + assert!(matches!( + exact_metric( + &snapshot, + "buzz_readiness_checks_total", + &[("reason", "redis_pool_timeout")] + ), + Some(DebugValue::Counter(1)) + )); + } + + #[test] + fn slow_older_success_cannot_overwrite_newer_failure_gauges() { + let coordinator = ReadinessCoordinator::default(); + let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { + panic!("serving probe A"); + }; + let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { + panic!("serving probe B"); + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + coordinator.finish_probe(fast_b, redis_failure_evaluation()); + coordinator.finish_probe(slow_a, ready_evaluation()); + }); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(gauge_value(&snapshot, "overall"), 0.0); + assert_eq!(gauge_value(&snapshot, "postgres"), 1.0); + assert_eq!(gauge_value(&snapshot, "redis"), 0.0); + assert_eq!(gauge_value(&snapshot, "deletion_catalog"), 1.0); + } + + #[test] + fn shutdown_fast_path_preserves_dependency_state_and_histograms() { + let coordinator = ReadinessCoordinator::default(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { + panic!("initial serving probe"); + }; + coordinator.finish_probe(ticket, ready_evaluation()); + coordinator.begin_shutdown(); + assert!(matches!( + coordinator.begin_probe(), + ProbeStart::ShuttingDown + )); + }); + let after = snapshotter.snapshot().into_vec(); + + for dependency in ["postgres", "redis", "deletion_catalog"] { + assert_eq!( + gauge_value(&after, dependency), + 1.0, + "shutdown must not fabricate {dependency} state" + ); + } + for check in ["overall", "postgres", "redis", "deletion_catalog"] { + assert!( + matches!( + exact_metric( + &after, + "buzz_readiness_check_duration_seconds", + &[("check", check)] + ), + Some(DebugValue::Histogram(values)) if values.len() == 1 + ), + "shutdown fast path must not add a {check} duration" + ); + } + assert_eq!(gauge_value(&after, "overall"), 0.0); + assert!(matches!( + exact_metric( + &after, + "buzz_readiness_checks_total", + &[("reason", "shutting_down")] + ), + Some(DebugValue::Counter(1)) + )); + } + + #[test] + fn shutdown_dominates_an_in_flight_success_without_resurrecting_gauges() { + let coordinator = ReadinessCoordinator::default(); + let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { + panic!("serving probe"); + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + let response = metrics::with_local_recorder(&recorder, || { + coordinator.begin_shutdown(); + coordinator.finish_probe(ticket, ready_evaluation()) + }); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(response.reason, ReadinessReason::ShuttingDown); + assert_eq!(gauge_value(&snapshot, "overall"), 0.0); + assert!( + exact_metric(&snapshot, "buzz_readiness_state", &[("check", "postgres")]).is_none() + ); + assert!(matches!( + exact_metric( + &snapshot, + "buzz_readiness_dependency_checks_total", + &[("dependency", "postgres"), ("outcome", "success")] + ), + Some(DebugValue::Counter(1)) + )); + } +} diff --git a/crates/buzz-relay/src/rejection.rs b/crates/buzz-relay/src/rejection.rs new file mode 100644 index 00000000000..96b8074e552 --- /dev/null +++ b/crates/buzz-relay/src/rejection.rs @@ -0,0 +1,336 @@ +//! How a rejected client frame is addressed back to the client. +//! +//! NIP-01 gives every request type its own acknowledgement channel, and a +//! rejection is only actionable if it travels on the same one: a REQ or COUNT +//! refusal settles on `CLOSED`, an EVENT on `OK`. Rejecting an EVENT with a bare +//! `NOTICE` leaves a client that tracks pending publishes by event id with +//! nothing to key on, so the send cannot fail — it can only time out. + +use crate::admission::AdmissionError; +use crate::connection::{AuthState, ConnectionState}; +use crate::protocol::{ClientMessage, RelayMessage}; +use crate::state::AppState; +use buzz_auth::LimitType; + +/// What a rejected client frame is correlated back to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RejectionTarget<'a> { + /// A REQ or COUNT names the query it opened. + Subscription(&'a str), + /// An EVENT names the event it submitted. + Event(nostr::EventId), + /// No per-request correlation exists — connection-scoped notice. + Connection, +} + +/// Picks the acknowledgement channel a rejection of `msg` must travel on. +pub(crate) fn rejection_target_for(msg: &ClientMessage) -> RejectionTarget<'_> { + match msg { + ClientMessage::Req { sub_id, .. } | ClientMessage::Count { sub_id, .. } => { + RejectionTarget::Subscription(sub_id.as_str()) + } + ClientMessage::Event(event) => RejectionTarget::Event(event.id), + _ => RejectionTarget::Connection, + } +} + +/// Renders `reason` as the rejection frame `target`'s acknowledgement channel +/// expects. +pub(crate) fn request_rejection_message(target: RejectionTarget<'_>, reason: &str) -> String { + match target { + RejectionTarget::Subscription(sub_id) => RelayMessage::closed(sub_id, reason), + RejectionTarget::Event(event_id) => RelayMessage::ok(&event_id.to_hex(), false, reason), + RejectionTarget::Connection => RelayMessage::notice(reason), + } +} + +/// Applies the WebSocket admission quotas to `msg`, returning whether it may be +/// handled. A rejection is addressed to the frame's own acknowledgement channel. +pub(crate) async fn enforce_ws_admission( + msg: &ClientMessage, + conn: &ConnectionState, + state: &AppState, +) -> bool { + let is_event = matches!(msg, ClientMessage::Event(_)); + if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) { + return true; + } + + let (pubkey, is_agent) = { + let auth = conn.auth_state.read().await; + match &*auth { + AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()), + _ => return true, + } + }; + + let limits = &state.auth.config().rate_limits; + let (ws_window_secs, ws_limit) = + crate::admission::ws_admission_budget(limits.human_ws_events_per_sec); + let ws_result = crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + &conn.tenant, + &pubkey, + LimitType::WsEvents, + ws_window_secs, + ws_limit, + ) + .await; + if !send_admission_result(conn, ws_result, msg) { + return false; + } + + if is_event { + let message_limit = if is_agent { + limits.agent_standard_messages_per_min + } else { + limits.human_messages_per_min + }; + let message_result = crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + &conn.tenant, + &pubkey, + LimitType::Messages, + 60, + message_limit, + ) + .await; + // The per-minute message quota only applies to EVENTs, and its + // rejection must be as correlatable as the burst quota's. + if !send_admission_result(conn, message_result, msg) { + return false; + } + } + + true +} + +/// Forwards an admission verdict to the client, returning whether the frame was +/// admitted. +/// +/// The rejection target is derived from `msg` here rather than supplied by the +/// caller: every quota check in this module must address its rejection to the +/// rejected frame's own acknowledgement channel, so there is deliberately no way +/// for a call site to name a different one. +fn send_admission_result( + conn: &ConnectionState, + result: Result<(), AdmissionError>, + msg: &ClientMessage, +) -> bool { + let target = rejection_target_for(msg); + match result { + Ok(()) => true, + Err(AdmissionError::Exceeded { reset_in_secs }) => { + metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1); + conn.send(request_rejection_message( + target, + &format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), + )); + false + } + Err(AdmissionError::Unavailable) => { + metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1); + conn.send(request_rejection_message( + target, + "rate-limited: shared admission unavailable", + )); + false + } + } +} + +#[cfg(test)] +mod tests { + //! A rejected frame must be answerable on the acknowledgement channel the + //! client is actually waiting on. + //! + //! History: an over-quota EVENT used to be rejected with a bare + //! `["NOTICE", reason]`. A NOTICE carries no event id, and desktop/mobile + //! settle pending publishes only from an `OK` keyed by event id, so the + //! rejection was unaddressable: the send could not fail, it could only time + //! out (25s in Desktop, `PUBLISH_TIMEOUT_MS`) and surface as a message stuck + //! on "Sending…". Startup quota exhaustion made it routine in the first + //! seconds after launch. + //! + //! These tests drive the production rejection path — a real parsed + //! `ClientMessage` through `enforce_ws_admission` and + //! `send_admission_result` — and assert on the frame that reaches the + //! connection's outbound channel. + + use std::sync::Arc; + + use axum::extract::ws::Message as WsMessage; + use nostr::{EventBuilder, Keys, Kind}; + use tokio::sync::mpsc; + + use crate::connection::tests::{authenticated_state, read_frame, test_conn_with_auth}; + use crate::connection::AuthState; + + use super::*; + + fn sent_frame(rx: &mut mpsc::Receiver) -> serde_json::Value { + read_frame(rx) + } + + fn test_conn() -> (Arc, mpsc::Receiver) { + test_conn_with_auth(AuthState::Failed) + } + + /// Parses a real EVENT frame exactly as the recv loop does, so the test is + /// coupled to production parsing and not to a hand-built target. + fn parsed_event_message() -> (ClientMessage, String) { + let event = EventBuilder::new(Kind::TextNote, "hello") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let event_id = event.id.to_hex(); + let frame = serde_json::json!(["EVENT", event]).to_string(); + (ClientMessage::parse(&frame).expect("parse EVENT"), event_id) + } + + /// The regression: an over-quota EVENT must be rejected with + /// `OK(event_id, false, reason)` so the client can settle the exact pending + /// publish it belongs to. A NOTICE here reintroduces the 25s send stall. + #[test] + fn over_quota_event_is_rejected_with_a_correlated_ok() { + let (conn, mut rx) = test_conn(); + let (msg, event_id) = parsed_event_message(); + + let admitted = send_admission_result( + &conn, + Err(AdmissionError::Exceeded { reset_in_secs: 7 }), + &msg, + ); + + assert!(!admitted, "an over-quota frame is not admitted"); + let frame = sent_frame(&mut rx); + assert_eq!( + frame[0], "OK", + "an EVENT rejection must travel on the OK channel — a NOTICE cannot \ + be correlated to a pending publish, so the send hangs until the \ + client's publish timeout instead of failing" + ); + assert_eq!( + frame[1], event_id, + "the OK must name the rejected event id, which is what the client's \ + pending-publish map is keyed by" + ); + assert_eq!(frame[2], false, "and must be an explicit rejection"); + assert_eq!( + frame[3], "rate-limited: quota exceeded; retry in 7s", + "the retry hint must survive so the client can arm its gate" + ); + } + + /// The same correlation is required when admission is unavailable rather + /// than exceeded — both branches strand a send if they emit a NOTICE. + #[test] + fn event_rejected_for_unavailable_admission_is_also_correlated() { + let (conn, mut rx) = test_conn(); + let (msg, event_id) = parsed_event_message(); + + send_admission_result(&conn, Err(AdmissionError::Unavailable), &msg); + + let frame = sent_frame(&mut rx); + assert_eq!(frame[0], "OK"); + assert_eq!(frame[1], event_id); + assert_eq!(frame[2], false); + } + + /// A REQ still settles on CLOSED, which carries the subscription id. This + /// pins the pre-existing behavior the fix must not disturb. + #[test] + fn over_quota_req_still_closes_the_subscription() { + let (conn, mut rx) = test_conn(); + let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); + let msg = ClientMessage::parse(&raw).expect("parse REQ"); + + send_admission_result( + &conn, + Err(AdmissionError::Exceeded { reset_in_secs: 7 }), + &msg, + ); + + let frame = sent_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!( + frame[1], "history-abc", + "a REQ rejection must name the subscription it rejected" + ); + assert_eq!(frame[2], "rate-limited: quota exceeded; retry in 7s"); + } + + /// NIP-45 uses `CLOSED(query_id, reason)` when a relay refuses a COUNT. + #[test] + fn over_quota_count_closes_the_query() { + let (conn, mut rx) = test_conn(); + let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); + let msg = ClientMessage::parse(&raw).expect("parse COUNT"); + + send_admission_result( + &conn, + Err(AdmissionError::Exceeded { reset_in_secs: 7 }), + &msg, + ); + + let frame = sent_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "count-abc"); + assert_eq!(frame[2], "rate-limited: quota exceeded; retry in 7s"); + } + + /// Drives the real entry point `handle_text_message` calls, so the wiring + /// between `enforce_ws_admission` and the target choice is under test and + /// not just the leaf renderer. + /// + /// The state's Redis is deliberately unreachable, which makes admission + /// return `Unavailable` — a production rejection path that needs no live + /// quota burst to reach. + async fn enforce_against_unreachable_admission(raw: &str) -> serde_json::Value { + let state = crate::state::tests::test_state().await; + let (conn, mut rx) = test_conn_with_auth(authenticated_state()); + let msg = ClientMessage::parse(raw).expect("parse client frame"); + + let admitted = enforce_ws_admission(&msg, &conn, &state).await; + assert!(!admitted, "an unadmitted frame must not be handled"); + sent_frame(&mut rx) + } + + #[tokio::test] + async fn enforce_ws_admission_rejects_an_event_on_the_ok_channel() { + let event = EventBuilder::new(Kind::TextNote, "hello") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let event_id = event.id.to_hex(); + let raw = serde_json::json!(["EVENT", event]).to_string(); + + let frame = enforce_against_unreachable_admission(&raw).await; + + assert_eq!( + frame[0], "OK", + "the admission gate must reject an EVENT on the channel the client's \ + pending publish is keyed by, or the send can only time out" + ); + assert_eq!(frame[1], event_id); + assert_eq!(frame[2], false); + } + + #[tokio::test] + async fn enforce_ws_admission_rejects_a_count_on_the_closed_channel() { + let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); + + let frame = enforce_against_unreachable_admission(&raw).await; + + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "count-abc"); + } + + #[tokio::test] + async fn enforce_ws_admission_rejects_a_req_on_the_closed_channel() { + let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); + + let frame = enforce_against_unreachable_admission(&raw).await; + + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "history-abc"); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index dd0fde6fdcd..61aedf70be0 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -24,6 +24,7 @@ use crate::audio; use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; +use crate::readiness::{self, ReadinessEvaluation, ReadinessReason}; use crate::state::AppState; /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. @@ -67,7 +68,7 @@ pub fn build_router(state: Arc) -> Router { // Health endpoints .route("/health", get(health_handler)) .route("/_liveness", get(liveness_handler)) - .route("/_readiness", get(readiness_handler)) + .route("/_readiness", get(public_readiness_handler)) // Nostr HTTP bridge (NIP-98 auth) .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) @@ -294,7 +295,7 @@ async fn admin_spa_document(state: &AppState, accept: &str) -> axum::response::R pub fn build_health_router(state: Arc) -> Router { Router::new() .route("/_liveness", get(liveness_handler)) - .route("/_readiness", get(readiness_handler)) + .route("/_readiness", get(kubernetes_readiness_handler)) .route("/_status", get(status_handler)) .route("/_mesh", get(mesh_status_handler)) .with_state(state) @@ -406,11 +407,36 @@ async fn liveness_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } -/// Readiness probe — checks shutdown flag, Postgres, and Redis connectivity. -async fn readiness_handler(State(state): State>) -> impl IntoResponse { - use std::time::Duration; +/// Compatibility endpoint on the public listener. It evaluates dependencies +/// and preserves the existing response contract but never records rollout +/// telemetry. +async fn public_readiness_handler(State(state): State>) -> impl IntoResponse { + if !state.readiness.public_evaluation_allowed() { + return readiness_response(ReadinessEvaluation::shutting_down(), false); + } + + let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; + let evaluation = state.readiness.finish_public_evaluation(evaluation); + readiness_response(evaluation, false) +} + +/// Kubernetes health-listener endpoint. All rollout metrics flow through the +/// process-owned coordinator so shutdown and probe generations are ordered. +async fn kubernetes_readiness_handler(State(state): State>) -> impl IntoResponse { + let readiness::ProbeStart::Evaluate(ticket) = state.readiness.begin_probe() else { + return readiness_response(ReadinessEvaluation::shutting_down(), true); + }; + + let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; + let evaluation = state.readiness.finish_probe(ticket, evaluation); + readiness_response(evaluation, true) +} - if state.shutting_down.load(Ordering::Relaxed) { +fn readiness_response( + evaluation: ReadinessEvaluation, + include_reason: bool, +) -> axum::response::Response { + if evaluation.reason == ReadinessReason::ShuttingDown { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"status": "shutting_down"})), @@ -418,33 +444,23 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo .into_response(); } - let check = async { - let (pg_ok, redis_ok, deletion_catalog_ok) = tokio::join!( - state.db.ping(), - async { state.redis_pool.get().await.is_ok() }, - async { state.db.validate_deletion_serving_catalog().await.is_ok() }, - ); - (pg_ok, redis_ok, deletion_catalog_ok) - }; - - let (pg_ok, redis_ok, deletion_catalog_ok) = - tokio::time::timeout(Duration::from_secs(2), check) - .await - .unwrap_or((false, false, false)); + let pg_ok = evaluation.postgres_ready(); + let redis_ok = evaluation.redis_ready(); + let deletion_catalog_ok = evaluation.deletion_catalog_ready(); - if pg_ok && redis_ok && deletion_catalog_ok { + if evaluation.is_ready() { (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() } else { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "status": "not_ready", - "postgres": pg_ok, - "redis": redis_ok, - "deletion_catalog": deletion_catalog_ok - })), - ) - .into_response() + let mut payload = json!({ + "status": "not_ready", + "postgres": pg_ok, + "redis": redis_ok, + "deletion_catalog": deletion_catalog_ok + }); + if include_reason { + payload["reason"] = json!(evaluation.reason.label()); + } + (StatusCode::SERVICE_UNAVAILABLE, Json(payload)).into_response() } } @@ -506,12 +522,17 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { #[cfg(test)] mod tests { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::sync::{Mutex, PoisonError}; + use std::time::Duration; + use axum::{routing::get, Router}; use futures_util::SinkExt; use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; use tokio::net::TcpListener; - use tokio::sync::mpsc; + use tokio::sync::{mpsc, Notify}; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tower::ServiceBuilder; use tracing::Instrument as _; @@ -519,6 +540,98 @@ mod tests { use super::*; + struct ScriptedReadinessEvaluator { + evaluations: Mutex>, + } + + impl ScriptedReadinessEvaluator { + fn new(evaluations: impl IntoIterator) -> Self { + Self { + evaluations: Mutex::new(evaluations.into_iter().collect()), + } + } + + fn push(&self, evaluation: ReadinessEvaluation) { + self.evaluations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push_back(evaluation); + } + } + + #[async_trait::async_trait] + impl readiness::ReadinessEvaluator for ScriptedReadinessEvaluator { + async fn evaluate( + &self, + _db: &buzz_db::Db, + _redis_pool: &deadpool_redis::Pool, + ) -> ReadinessEvaluation { + self.evaluations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .pop_front() + .expect("scripted readiness evaluation") + } + } + + struct BarrierReadinessEvaluator { + calls: AtomicUsize, + first_started: Notify, + release_first: Notify, + first: ReadinessEvaluation, + second: ReadinessEvaluation, + } + + impl BarrierReadinessEvaluator { + fn new(first: ReadinessEvaluation, second: ReadinessEvaluation) -> Self { + Self { + calls: AtomicUsize::new(0), + first_started: Notify::new(), + release_first: Notify::new(), + first, + second, + } + } + } + + #[async_trait::async_trait] + impl readiness::ReadinessEvaluator for BarrierReadinessEvaluator { + async fn evaluate( + &self, + _db: &buzz_db::Db, + _redis_pool: &deadpool_redis::Pool, + ) -> ReadinessEvaluation { + if self.calls.fetch_add(1, AtomicOrdering::SeqCst) == 0 { + self.first_started.notify_waiters(); + self.release_first.notified().await; + self.first + } else { + self.second + } + } + } + + fn readiness_evaluation( + postgres: readiness::PostgresOutcome, + redis: readiness::RedisOutcome, + deletion_catalog: readiness::DeletionCatalogOutcome, + ) -> ReadinessEvaluation { + ReadinessEvaluation::from_results( + readiness::TimedOutcome::new(postgres, Duration::from_millis(35)), + readiness::TimedOutcome::new(redis, Duration::from_millis(20)), + readiness::TimedOutcome::new(deletion_catalog, Duration::from_millis(15)), + Duration::from_millis(35), + ) + } + + fn ready_evaluation() -> ReadinessEvaluation { + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ) + } + #[test] fn invite_landing_path_requires_exactly_one_nonempty_code_segment() { assert!(is_invite_landing_path("/invite/payload.mac")); @@ -594,6 +707,447 @@ mod tests { Arc::new(state) } + async fn readiness_state(evaluator: Arc) -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + 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 (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.set_readiness_evaluator(evaluator); + Arc::new(state) + } + + async fn readiness_request(router: Router) -> (StatusCode, serde_json::Value) { + let response = router + .oneshot( + Request::get("/_readiness") + .body(Body::empty()) + .expect("readiness request"), + ) + .await + .expect("readiness response"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("readiness response body"); + let payload = serde_json::from_slice(&body).expect("readiness JSON"); + (status, payload) + } + + fn readiness_metric_lines(rendered: &str) -> Vec<&str> { + rendered + .lines() + .filter(|line| line.starts_with("buzz_readiness")) + .collect() + } + + fn sorted_readiness_metric_lines(rendered: &str) -> Vec { + let mut lines = readiness_metric_lines(rendered) + .into_iter() + .map(str::to_owned) + .collect::>(); + lines.sort(); + lines + } + + fn metric_value(rendered: &str, exact_prefix: &str) -> f64 { + rendered + .lines() + .find_map(|line| { + line.strip_prefix(exact_prefix) + .and_then(|value| value.strip_prefix(' ')) + .and_then(|value| value.parse().ok()) + }) + .unwrap_or_else(|| panic!("missing metric line: {exact_prefix}")) + } + + #[test] + fn production_readiness_routes_export_the_frozen_health_only_contract() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let evaluator = Arc::new(ScriptedReadinessEvaluator::new(std::iter::repeat_n( + ready_evaluation(), + 4, + ))); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + crate::metrics::describe_readiness_metrics(); + runtime.block_on(async { + let state = readiness_state(evaluator.clone()).await; + let public = build_router(state.clone()); + let health = build_health_router(state.clone()); + + for _ in 0..3 { + assert_eq!( + readiness_request(public.clone()).await, + (StatusCode::OK, json!({"status": "ready"})) + ); + } + assert!( + readiness_metric_lines(&handle.render()).is_empty(), + "public compatibility requests must emit no readiness series" + ); + + assert_eq!( + readiness_request(health.clone()).await, + (StatusCode::OK, json!({"status": "ready"})) + ); + let first_scrape = handle.render(); + + assert!(first_scrape.contains("# TYPE buzz_readiness_checks_total counter")); + assert!(first_scrape + .contains("# TYPE buzz_readiness_dependency_checks_total counter")); + assert!(first_scrape + .contains("# TYPE buzz_readiness_check_duration_seconds histogram")); + assert!(first_scrape.contains("# TYPE buzz_readiness_state gauge")); + assert_eq!( + metric_value( + &first_scrape, + "buzz_readiness_checks_total{reason=\"ready\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &first_scrape, + "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &first_scrape, + "buzz_readiness_state{check=\"overall\"}" + ), + 1.0 + ); + for bucket in ["2", "2.5", "+Inf"] { + assert!(first_scrape.contains(&format!( + "buzz_readiness_check_duration_seconds_bucket{{check=\"overall\",le=\"{bucket}\"}}" + ))); + } + assert!(!first_scrape.contains("result=")); + assert!(!first_scrape + .lines() + .filter(|line| line.starts_with("buzz_readiness_check_duration_seconds")) + .any(|line| line.contains("outcome="))); + + let before_public_failure = sorted_readiness_metric_lines(&first_scrape); + evaluator.push(readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + )); + assert_eq!( + readiness_request(public.clone()).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({ + "status": "not_ready", + "postgres": true, + "redis": false, + "deletion_catalog": true + }) + ) + ); + assert_eq!( + sorted_readiness_metric_lines(&handle.render()), + before_public_failure + ); + + let contract_evaluations = [ + readiness_evaluation( + readiness::PostgresOutcome::PoolTimeout, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::PoolError, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::QueryTimeout, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::QueryError, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolError, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::OperationTimeout, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::OperationError, + ), + readiness_evaluation( + readiness::PostgresOutcome::PoolTimeout, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::OperationTimeout, + ), + readiness_evaluation( + readiness::PostgresOutcome::PoolError, + readiness::RedisOutcome::PoolError, + readiness::DeletionCatalogOutcome::Success, + ), + ]; + for evaluation in contract_evaluations { + evaluator.push(evaluation); + let (status, payload) = readiness_request(health.clone()).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(payload["reason"], json!(evaluation.reason.label())); + } + + let before_shutdown = handle.render(); + let histogram_counts_before = ["overall", "postgres", "redis", "deletion_catalog"] + .map(|check| { + metric_value( + &before_shutdown, + &format!( + "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" + ), + ) + }); + state.begin_shutdown(); + assert_eq!( + readiness_request(public).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ) + ); + let after_public_shutdown = handle.render(); + assert!(after_public_shutdown + .lines() + .all(|line| !line.contains("reason=\"shutting_down\""))); + + assert_eq!( + readiness_request(health).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ) + ); + let final_scrape = handle.render(); + let histogram_counts_after = ["overall", "postgres", "redis", "deletion_catalog"] + .map(|check| { + metric_value( + &final_scrape, + &format!( + "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" + ), + ) + }); + assert_eq!(histogram_counts_after, histogram_counts_before); + assert_eq!( + metric_value( + &final_scrape, + "buzz_readiness_checks_total{reason=\"shutting_down\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &final_scrape, + "buzz_readiness_state{check=\"overall\"}" + ), + 0.0 + ); + assert!(!final_scrape.contains("sensitive-sql-or-url")); + + let exported_reasons = final_scrape + .lines() + .filter(|line| line.starts_with("buzz_readiness_checks_total{")) + .count(); + assert_eq!(exported_reasons, readiness::READINESS_REASON_LABELS.len()); + assert_eq!( + readiness_metric_lines(&final_scrape).len(), + readiness::READINESS_RAW_SERIES_PER_POD, + "readiness series contract must stay at or below its 99-series cap" + ); + }); + }); + } + + fn run_out_of_order_route_case( + first: ReadinessEvaluation, + second: ReadinessEvaluation, + ) -> (serde_json::Value, serde_json::Value, String) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let evaluator = Arc::new(BarrierReadinessEvaluator::new(first, second)); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let state = readiness_state(evaluator.clone()).await; + let health = build_health_router(state); + let first_started = evaluator.first_started.notified(); + let slow_first = tokio::spawn(readiness_request(health.clone())); + first_started.await; + + let (_, second_payload) = readiness_request(health).await; + evaluator.release_first.notify_one(); + let (_, first_payload) = slow_first.await.expect("slow first probe task"); + (first_payload, second_payload, handle.render()) + }) + }) + } + + #[test] + fn real_health_route_generation_fence_covers_both_completion_orders() { + let failure = readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + ); + + let (older_failure, newer_success, success_scrape) = + run_out_of_order_route_case(failure, ready_evaluation()); + assert_eq!(older_failure["reason"], json!("redis_pool_timeout")); + assert_eq!(newer_success, json!({"status": "ready"})); + assert_eq!( + metric_value(&success_scrape, "buzz_readiness_state{check=\"overall\"}"), + 1.0 + ); + assert_eq!( + metric_value(&success_scrape, "buzz_readiness_state{check=\"redis\"}"), + 1.0 + ); + + let (older_success, newer_failure, failure_scrape) = + run_out_of_order_route_case(ready_evaluation(), failure); + assert_eq!(older_success, json!({"status": "ready"})); + assert_eq!(newer_failure["reason"], json!("redis_pool_timeout")); + assert_eq!( + metric_value(&failure_scrape, "buzz_readiness_state{check=\"overall\"}"), + 0.0 + ); + assert_eq!( + metric_value(&failure_scrape, "buzz_readiness_state{check=\"redis\"}"), + 0.0 + ); + for scrape in [&success_scrape, &failure_scrape] { + assert_eq!( + metric_value(scrape, "buzz_readiness_checks_total{reason=\"ready\"}"), + 1.0 + ); + assert_eq!( + metric_value( + scrape, + "buzz_readiness_checks_total{reason=\"redis_pool_timeout\"}" + ), + 1.0 + ); + } + } + + #[test] + fn real_health_route_shutdown_fence_dominates_an_in_flight_success() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let evaluator = Arc::new(BarrierReadinessEvaluator::new( + ready_evaluation(), + ready_evaluation(), + )); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let state = readiness_state(evaluator.clone()).await; + let health = build_health_router(state.clone()); + let first_started = evaluator.first_started.notified(); + let in_flight = tokio::spawn(readiness_request(health)); + first_started.await; + + state.begin_shutdown(); + evaluator.release_first.notify_one(); + assert_eq!( + in_flight.await.expect("in-flight readiness task"), + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ) + ); + + let scrape = handle.render(); + assert_eq!( + metric_value(&scrape, "buzz_readiness_state{check=\"overall\"}"), + 0.0 + ); + assert!(scrape + .lines() + .all(|line| !line.starts_with("buzz_readiness_state{check=\"postgres\"}"))); + assert_eq!( + metric_value( + &scrape, + "buzz_readiness_checks_total{reason=\"shutting_down\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &scrape, + "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" + ), + 1.0 + ); + }); + }); + } + /// A minimal built SPA: an index document, one hashed asset, and the /// root-level favicon Vite copies out of `public/`. fn write_bundle(dir: &std::path::Path) { diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index ab3f1d8c7eb..95372d5bc3b 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -668,6 +668,12 @@ pub struct AppState { pub workflow_engine: Arc, /// Relay signing keypair — used to sign system messages (kind 40099). pub relay_keypair: nostr::Keys, + /// Process-local generation advertised for non-mesh huddle liveness. + /// + /// A fresh value on every relay start lets desktop clients retire persisted + /// admissions when an in-memory audio room is recreated at the same roster + /// revision after a restart. Mesh rooms use their Redis-fenced generation. + pub huddle_liveness_generation: Uuid, /// Recently-published event IDs for local-echo deduplication, keyed by /// `(community_id, event_id)`. Events fanned out in-process are added here; @@ -713,6 +719,8 @@ pub struct AppState { pub audio_rooms: Arc, /// Set to `true` on SIGTERM — readiness probe returns 503. pub shutting_down: Arc, + /// Orders readiness gauge publication against terminal shutdown. + pub(crate) readiness: Arc, /// Process start time — used by `/_status` endpoint. pub started_at: Instant, /// Shared, community-scoped NIP-98 replay prevention. @@ -877,6 +885,7 @@ impl AppState { media_upload_semaphore: Arc::new(Semaphore::new(media_max_concurrent_uploads)), workflow_engine, relay_keypair, + huddle_liveness_generation: Uuid::new_v4(), local_event_ids: Arc::new( moka::sync::Cache::builder() @@ -914,6 +923,7 @@ impl AppState { git_pack_cache, audio_rooms: Arc::new(AudioRoomManager::new()), shutting_down: Arc::new(AtomicBool::new(false)), + readiness: Arc::new(crate::readiness::ReadinessCoordinator::default()), started_at: Instant::now(), nip98_replay, gif_http_client, @@ -955,6 +965,23 @@ impl AppState { ) } + /// Atomically closes readiness publication before exposing shutdown to + /// the relay's other fast-path lifecycle checks. + pub fn begin_shutdown(&self) { + self.readiness.begin_shutdown(); + self.shutting_down.store(true, Ordering::Release); + } + + #[cfg(test)] + pub(crate) fn set_readiness_evaluator( + &mut self, + evaluator: Arc, + ) { + self.readiness = Arc::new(crate::readiness::ReadinessCoordinator::with_evaluator( + evaluator, + )); + } + /// Inter-relay mesh handle. `None` ⇒ mesh-off / single-instance: callers /// must no-op to today's behavior. Set once by `main.rs` after boot. pub fn mesh(&self) -> Option<&crate::mesh_boot::MeshHandle> { @@ -1224,7 +1251,7 @@ impl AppState { pub async fn revalidate_live_communities(&self) -> usize { let (closed, failures) = revalidate_registered_communities(&self.community_connections, |community_id| { - self.db.is_community_active(community_id) + self.db.is_community_active_for_maintenance(community_id) }) .await; for (community_id, error) in failures { @@ -1346,11 +1373,33 @@ impl AuditShutdownHandle { /// and the post-cancel drain share the same logic. async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::NewAuditEntry) { let t = std::time::Instant::now(); - if let Err(e) = audit.log(entry).await { - metrics::counter!("buzz_audit_log_errors_total").increment(1); - tracing::error!("Audit log failed: {e}"); - } else { - metrics::histogram!("buzz_audit_log_seconds").record(t.elapsed().as_secs_f64()); + let mut retry_delay_ms = 50u64; + let mut retries = 0u64; + loop { + match audit.log(entry.clone()).await { + Ok(_) => { + metrics::histogram!("buzz_audit_log_seconds").record(t.elapsed().as_secs_f64()); + return; + } + Err(buzz_audit::AuditError::Database(sqlx::Error::Database(database_error))) + if database_error.code().as_deref() == Some("55P03") => + { + retries += 1; + metrics::counter!("buzz_audit_log_lock_retries_total").increment(1); + tracing::warn!( + retries, + retry_delay_ms, + "Audit advisory lock timed out; preserving entry for retry" + ); + tokio::time::sleep(std::time::Duration::from_millis(retry_delay_ms)).await; + retry_delay_ms = (retry_delay_ms * 2).min(1_000); + } + Err(error) => { + metrics::counter!("buzz_audit_log_errors_total").increment(1); + tracing::error!("Audit log failed: {error}"); + return; + } + } } } @@ -1364,7 +1413,7 @@ impl std::fmt::Debug for AppState { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::connection::{AuthState, ConnectionState}; use std::collections::HashMap; @@ -1403,7 +1452,10 @@ mod tests { (mgr, conn_id, rx, ctrl_rx, cancel, bp) } - async fn test_state() -> Arc { + /// A relay state whose Redis is deliberately unreachable, so admission + /// checks resolve to `AdmissionError::Unavailable` without any live + /// infrastructure. Shared with `crate::rejection`'s tests. + pub(crate) async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); @@ -1440,6 +1492,137 @@ mod tests { Arc::new(state) } + async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); + let observer = sqlx::PgPool::connect(&database_url) + .await + .expect("connect observer pool"); + let application_name = format!("audit-retry-test-{}", Uuid::new_v4()); + let hook_application_name = application_name.clone(); + let audit_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(move |conn, _meta| { + let application_name = hook_application_name.clone(); + Box::pin(async move { + sqlx::query( + "SELECT set_config('application_name', $1, false), \ + set_config('lock_timeout', '100', false)", + ) + .bind(application_name) + .execute(&mut *conn) + .await?; + Ok(()) + }) + }) + .connect(&database_url) + .await + .expect("connect audit pool"); + + let community_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("audit-retry-{community_id}.example")) + .execute(&observer) + .await + .expect("insert test community"); + let object_id = format!("audit-retry-object-{}", Uuid::new_v4()); + let entry = buzz_audit::NewAuditEntry { + community_id: CommunityId::from_uuid(community_id), + action: buzz_audit::AuditAction::EventCreated, + actor_pubkey: Some(vec![0xab; 32]), + object_id: Some(object_id.clone()), + detail: serde_json::json!({"test": "lock-timeout-retry"}), + }; + + // Mirrors buzz_audit::service::AUDIT_LOCK_NAMESPACE. + let lock_key = format!("buzz_audit:{community_id}"); + let mut holder = observer.acquire().await.expect("acquire lock holder"); + sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + .expect("hold community audit lock"); + + let audit = Arc::new(AuditService::new(audit_pool)); + let worker = tokio::spawn({ + let audit = Arc::clone(&audit); + async move { log_audit_entry(&audit, entry).await } + }); + + // Observe one timed-out advisory-lock attempt and then a second wait. + // Releasing during the first wait would not prove that the worker + // preserved and retried the original queue entry. + tokio::time::timeout(std::time::Duration::from_secs(3), async { + let mut saw_first_wait = false; + let mut saw_retry_gap = false; + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM pg_stat_activity \ + WHERE application_name = $1 \ + AND query LIKE 'SELECT pg_advisory_lock%' \ + AND wait_event = 'advisory'\ + )", + ) + .bind(&application_name) + .fetch_one(&observer) + .await + .expect("inspect audit lock waiter"); + if waiting { + if saw_retry_gap { + break; + } + saw_first_wait = true; + } else if saw_first_wait { + saw_retry_gap = true; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("audit worker never retried after lock_timeout"); + + sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + .expect("release community audit lock"); + tokio::time::timeout(std::time::Duration::from_secs(3), worker) + .await + .expect("audit worker did not finish after lock release") + .expect("audit worker task panicked"); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM audit_log WHERE community_id = $1 AND object_id = $2", + ) + .bind(community_id) + .bind(&object_id) + .fetch_one(&observer) + .await + .expect("count retried audit rows"); + assert_eq!(rows, 1, "the preserved entry must be appended exactly once"); + + sqlx::query("DELETE FROM audit_log WHERE community_id = $1 AND object_id = $2") + .bind(community_id) + .bind(&object_id) + .execute(&observer) + .await + .expect("remove test audit row"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&observer) + .await + .expect("remove test community"); + } + + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { + super::audit_worker_retries_lock_timeout_until_original_entry_is_appended_once().await; + } + } + #[test] fn send_to_resets_grace_counter_on_success() { let (mgr, id, _rx, _ctrl_rx, _cancel, bp) = setup_conn(16); diff --git a/crates/buzz-relay/src/telemetry.rs b/crates/buzz-relay/src/telemetry.rs index 91bd92f0f3e..7ffd6a7330b 100644 --- a/crates/buzz-relay/src/telemetry.rs +++ b/crates/buzz-relay/src/telemetry.rs @@ -223,9 +223,9 @@ pub enum TracerInit { Enabled(SdkTracerProvider), /// `OTEL_EXPORTER_OTLP_ENDPOINT` was unset — no-op, no connection. Disabled, - /// Endpoint was set but the exporter failed to build. The inner error - /// string is suitable for a `tracing::warn!` call made by the caller - /// **after** `tracing_subscriber::registry()…init()`. + /// Endpoint was set but the exporter failed to build. The inner error is + /// diagnostic data only and must not be logged: exporter errors can + /// include credential-bearing endpoint URLs. ExporterBuildFailed(String), } @@ -234,7 +234,8 @@ pub enum TracerInit { /// /// Deliberately does **not** call `tracing::warn!` internally — the subscriber /// may not be installed yet at call time, which would silently drop the event. -/// Callers are responsible for logging [`TracerInit::ExporterBuildFailed`]. +/// Callers may log a fixed, credential-free message for +/// [`TracerInit::ExporterBuildFailed`], but must not log its inner error. pub fn try_init_tracer(resource: Resource) -> TracerInit { if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_err() { return TracerInit::Disabled; diff --git a/crates/buzz-relay/src/test_support.rs b/crates/buzz-relay/src/test_support.rs new file mode 100644 index 00000000000..a0b9f685374 --- /dev/null +++ b/crates/buzz-relay/src/test_support.rs @@ -0,0 +1,108 @@ +const DEFAULT_DATABASE_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + +/// Resolve the database URL shared by PostgreSQL-backed relay tests. +pub(crate) fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("TEST_DATABASE_URL")) + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) +} + +#[cfg(test)] +const CHILD_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +#[cfg(test)] +const MAX_CAPTURE_BYTES: u64 = 1024 * 1024; + +#[cfg(test)] +struct CapturedStream { + retained: Vec, + total_bytes: u64, +} + +#[cfg(test)] +fn capture_stream(mut stream: impl std::io::Read) -> CapturedStream { + let mut retained = Vec::new(); + let mut total_bytes = 0_u64; + let mut chunk = [0_u8; 8192]; + loop { + let read = stream.read(&mut chunk).expect("read child output pipe"); + if read == 0 { + break; + } + total_bytes = total_bytes.saturating_add(u64::try_from(read).expect("read size fits u64")); + let remaining = usize::try_from(MAX_CAPTURE_BYTES) + .expect("capture ceiling fits usize") + .saturating_sub(retained.len()); + retained.extend_from_slice(&chunk[..read.min(remaining)]); + } + CapturedStream { + retained, + total_bytes, + } +} + +#[cfg(test)] +fn join_capture(capture: std::thread::JoinHandle, stream: &str) -> Vec { + let capture = capture.join().expect("child capture thread must not panic"); + assert!( + capture.total_bytes <= MAX_CAPTURE_BYTES, + "child {stream} exceeded {MAX_CAPTURE_BYTES} bytes: {}", + capture.total_bytes, + ); + capture.retained +} + +/// Run exactly one unit test in an isolated, deadline-bounded child process. +#[cfg(test)] +pub(crate) fn run_exact_test_child(test_name: &str, child_env: &str) { + use std::{ + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, + }; + + let mut child = Command::new(std::env::current_exe().expect("test executable")) + .arg("--exact") + .arg(test_name) + .arg("--nocapture") + .env(child_env, "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn isolated test child"); + let stdout = child.stdout.take().expect("child stdout pipe"); + let stderr = child.stderr.take().expect("child stderr pipe"); + let stdout = thread::spawn(move || capture_stream(stdout)); + let stderr = thread::spawn(move || capture_stream(stderr)); + + let deadline = Instant::now() + CHILD_TEST_TIMEOUT; + let (status, timed_out) = loop { + if let Some(status) = child.try_wait().expect("poll isolated test child") { + break (status, false); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let status = child.wait().expect("reap timed-out test child"); + break (status, true); + } + thread::sleep(Duration::from_millis(10)); + }; + + let stdout = join_capture(stdout, "stdout"); + let stderr = join_capture(stderr, "stderr"); + let output = format!( + "{}{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + + assert!( + !timed_out, + "isolated test child exceeded {CHILD_TEST_TIMEOUT:?}:\n{output}" + ); + assert!(status.success(), "isolated test child failed:\n{output}"); + assert!( + output.contains("running 1 test") && output.contains(test_name), + "exact selector did not run the intended test {test_name}:\n{output}" + ); +} diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 8ce23a2e8ea..6450b15b282 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -148,6 +148,39 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec, + rendered_text: &str, + authored_text: &str, + members: &[(String, String)], + author_pubkey_hex: &str, +) -> Result<(), ActionSinkError> { + let rendered_mentions = resolve_mention_pubkeys(rendered_text, members); + let authored_mentions: std::collections::HashSet = + resolve_mention_pubkeys(authored_text, members) + .into_iter() + .collect(); + + for mentioned in rendered_mentions { + if mentioned != author_pubkey_hex { + tags.push( + Tag::parse(["p", &mentioned]) + .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, + ); + } + if authored_mentions.contains(&mentioned) { + tags.push( + Tag::parse(["buzz:workflow-mention", &mentioned]).map_err(|e| { + ActionSinkError::EventBuild(format!("workflow mention tag: {e}")) + })?, + ); + } + } + Ok(()) +} + /// Relay-side action sink — executes workflow side-effects directly. /// /// Holds a **weak** reference to `AppState` to avoid an `Arc` reference cycle: @@ -175,11 +208,13 @@ impl ActionSink for RelayActionSink { community_id: CommunityId, channel_id: &str, text: &str, + authored_text: &str, author_pubkey: &str, reply_to: Option<&str>, ) -> Pin> + Send + '_>> { let channel_id = channel_id.to_owned(); let text = text.to_owned(); + let authored_text = authored_text.to_owned(); let author_pubkey = author_pubkey.to_owned(); let reply_to = reply_to.map(str::to_owned); @@ -222,7 +257,7 @@ impl ActionSink for RelayActionSink { let channel = state .db - .get_channel(tenant.community(), channel_uuid) + .get_channel_for_event_write(tenant.community(), channel_uuid) .await .map_err(|e| match &e { buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::NotFound(_) => { @@ -257,8 +292,14 @@ impl ActionSink for RelayActionSink { // - `p` tag attributes the message to the workflow owner // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering - // - one `p` tag per `@Name` that resolves to a channel member, - // so mentioned agents are woken (wake is `p`-tag gated) + // - `buzz:workflow-owner` lets harnesses apply the owner's + // inbound-author policy after verifying the relay signature + // - one `p` tag for every resolved mention in the rendered output, + // preserving legacy wake/feed behavior + // - one `buzz:workflow-mention` tag only when the same target was + // named in the workflow owner's stored step template. This is the + // authority-bearing provenance used by ACP; trigger-controlled + // template substitutions cannot create it. let mut tags = vec![ Tag::parse(["p", &author_pubkey_hex]) .map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?, @@ -266,6 +307,8 @@ impl ActionSink for RelayActionSink { .map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?, Tag::parse(["buzz:workflow", "true"]) .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, + Tag::parse(["buzz:workflow-owner", &author_pubkey_hex]) + .map_err(|e| ActionSinkError::EventBuild(format!("workflow owner tag: {e}")))?, ]; // Resolve thread ancestry when this is a threaded reply, so the @@ -312,19 +355,22 @@ impl ActionSink for RelayActionSink { } } - // Resolve `@Name` mentions to channel-member pubkeys and append a - // `p` tag for each (skipping the author, already tagged above). A - // resolution failure must not drop the message, so log and proceed - // with the base tags. + // Resolve `@Name` mentions to channel-member pubkeys. The rendered + // text supplies the legacy `p` tags used by subscriptions and feeds. + // The stored author-written template independently supplies the + // authority-bearing workflow-mention tags. A trigger may therefore + // render an `@Name` into visible output, but it cannot borrow the + // workflow owner's authority to wake that agent. A resolution failure + // must not drop the message, so log and proceed with the base tags. let members = state .db - .get_members(tenant.community(), channel_uuid) + .get_members_for_event_write(tenant.community(), channel_uuid) .await .map_err(|e| ActionSinkError::Database(e.to_string()))?; let member_pubkeys: Vec> = members.iter().map(|m| m.pubkey.clone()).collect(); let users = state .db - .get_users_bulk(tenant.community(), &member_pubkeys) + .get_users_bulk_for_event_write(tenant.community(), &member_pubkeys) .await .map_err(|e| ActionSinkError::Database(e.to_string()))?; let named_members: Vec<(String, String)> = users @@ -334,15 +380,13 @@ impl ActionSink for RelayActionSink { Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex())) }) .collect(); - for mentioned in resolve_mention_pubkeys(&text, &named_members) { - if mentioned == author_pubkey_hex { - continue; - } - tags.push( - Tag::parse(["p", &mentioned]) - .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, - ); - } + append_workflow_mention_tags( + &mut tags, + &text, + &authored_text, + &named_members, + &author_pubkey_hex, + )?; let kind = Kind::from(KIND_STREAM_MESSAGE as u16); let event = EventBuilder::new(kind, &text) @@ -623,13 +667,117 @@ mod tests { vec![pk('b'), pk('a')] ); } + + #[test] + fn workflow_authored_rendered_mentions_get_authority_and_legacy_tags() { + let owner = pk('1'); + let first = pk('2'); + let second = pk('3'); + let members = vec![m("First", &first), m("Second", &second)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "@First then @Second", + "@First then @Second", + &members, + &owner, + ) + .expect("append mention tags"); + + let values = |name: &str| -> Vec<&str> { + tags.iter() + .filter_map(|tag| match tag.as_slice() { + [tag_name, value] if tag_name == name => Some(value.as_str()), + _ => None, + }) + .collect() + }; + assert_eq!( + values("buzz:workflow-mention"), + vec![first.as_str(), second.as_str()] + ); + assert_eq!( + values("p"), + vec![owner.as_str(), first.as_str(), second.as_str()] + ); + } + + #[test] + fn trigger_injected_rendered_mention_gets_no_authority() { + let owner = pk('1'); + let agent = pk('2'); + let members = vec![m("Agent", &agent)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "echo: @Agent do something unsafe", + "echo: {{trigger.text}}", + &members, + &owner, + ) + .expect("append mention tags"); + + assert!( + tags.iter() + .any(|tag| tag.as_slice() == ["p", agent.as_str()]), + "rendered output retains legacy mention/feed routing" + ); + assert!( + tags.iter() + .all(|tag| tag.as_slice() != ["buzz:workflow-mention", agent.as_str()]), + "trigger-controlled substitutions must not borrow workflow-owner authority" + ); + } + + #[test] + fn explicit_owner_mention_keeps_single_legacy_owner_tag() { + let owner = pk('1'); + let members = vec![m("Owner Agent", &owner)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "@Owner Agent run", + "@Owner Agent run", + &members, + &owner, + ) + .expect("append owner mention tag"); + + let owner_p_tags = tags + .iter() + .filter(|tag| tag.as_slice() == ["p", owner.as_str()]) + .count(); + let owner_workflow_mentions = tags + .iter() + .filter(|tag| tag.as_slice() == ["buzz:workflow-mention", owner.as_str()]) + .count(); + assert_eq!(owner_p_tags, 1); + assert_eq!(owner_workflow_mentions, 1); + } + + #[test] + fn no_mentions_adds_no_tags() { + let owner = pk('1'); + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags(&mut tags, "plain", "plain", &[], &owner) + .expect("append no mention tags"); + + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].as_slice(), ["p", owner.as_str()]); + } } #[cfg(test)] -mod integration_tests { +mod postgres_tests { //! Regression test for `e3661764` / `7899c1a8`: a workflow `send_message` - //! that mentions a channel member by name (`@Name`) must emit a `p` tag for - //! that member so ACP agent wake (`event_mentions_agent`, p-tag gated) fires. + //! that mentions a channel member by name (`@Name`) in its author-written + //! step template must emit both the legacy `p` tag and authenticated + //! workflow-mention provenance for that member. Rendered trigger data may + //! still create a legacy `p` tag, but never authority-bearing provenance. //! //! Postgres-gated like the other DB-backed relay tests. Run with: //! `cargo test -p buzz-relay --lib workflow_sink -- --ignored` @@ -676,9 +824,79 @@ mod integration_tests { Arc::new(state) } + async fn execute_send_message_workflow( + state: &Arc, + community: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + name: &str, + authored_text: &str, + trigger_text: &str, + ) -> String { + let definition = serde_json::json!({ + "name": name, + "trigger": {"on": "message_posted"}, + "steps": [{ + "id": "send", + "action": "send_message", + "text": authored_text, + }], + "enabled": true, + }); + let definition_hash_byte = name.as_bytes().first().copied().unwrap_or_default(); + let workflow_id = state + .db + .create_workflow( + community, + Some(channel_id), + owner_pubkey, + name, + &definition.to_string(), + &[definition_hash_byte; 32], + ) + .await + .expect("create workflow"); + let trigger_ctx = buzz_workflow::executor::TriggerContext { + text: trigger_text.to_owned(), + channel_id: channel_id.to_string(), + ..Default::default() + }; + let trigger_ctx_json = serde_json::to_value(&trigger_ctx).expect("serialize trigger"); + let run_id = state + .db + .create_workflow_run(community, workflow_id, None, Some(&trigger_ctx_json)) + .await + .expect("create workflow run"); + + // Load the definition back from Postgres before execution. This pins the + // authority source to the durable owner-authored template rather than a + // second test-only string passed directly to RelayActionSink. + let stored_workflow = state + .db + .get_workflow(community, workflow_id) + .await + .expect("load stored workflow"); + let stored_definition: buzz_workflow::WorkflowDef = + serde_json::from_value(stored_workflow.definition).expect("parse stored definition"); + let result = buzz_workflow::executor::execute_run( + &state.workflow_engine, + community, + run_id, + &stored_definition, + &trigger_ctx, + ) + .await + .expect("execute workflow"); + + result.step_outputs["send"]["event_id"] + .as_str() + .expect("send_message event id") + .to_owned() + } + #[tokio::test] #[ignore = "requires Postgres"] - async fn workflow_send_message_p_tags_mentioned_member() { + async fn workflow_send_message_binds_authority_to_authored_mentions() { let state = test_state().await; let author = nostr::Keys::generate(); @@ -699,6 +917,12 @@ mod integration_tests { }; // Open channel; the creator (author) is bootstrapped as an owner-member. + let author_bytes = author.public_key().to_bytes().to_vec(); + state + .db + .ensure_user(community, &author_bytes) + .await + .expect("ensure workflow owner user row"); let channel = state .db .create_channel( @@ -736,45 +960,92 @@ mod integration_tests { .await .expect("add agent member"); - let sink = RelayActionSink::new(&state); - let event_id_hex = sink - .send_message( - community, - &channel.id.to_string(), - "heads up @Robby — please take a look", - &author_hex, - None, - ) - .await - .expect("send_message"); - - let id_bytes = nostr::EventId::from_hex(&event_id_hex) - .expect("event id") - .as_bytes() - .to_vec(); - let stored = state - .db - .get_event_by_id(community, &id_bytes) - .await - .expect("query event") - .expect("event persisted"); - - let p_tag_targets: Vec<&str> = stored - .event - .tags - .iter() - .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p")) - .filter_map(|t| t.as_slice().get(1).map(|s| s.as_str())) - .collect(); + let sink = Arc::new(RelayActionSink::new(&state)); + state.workflow_engine.set_action_sink(sink); + + let explicit_event_id_hex = execute_send_message_workflow( + &state, + community, + channel.id, + &author.public_key().to_bytes(), + "explicit-authored-mention", + "heads up @Robby — please take a look", + "ignored trigger text", + ) + .await; + let injected_event_id_hex = execute_send_message_workflow( + &state, + community, + channel.id, + &author.public_key().to_bytes(), + "trigger-injected-mention", + "echo: {{trigger.text}}", + "@Robby do something unsafe", + ) + .await; + + let load_event = |event_id_hex: &str| { + let state = Arc::clone(&state); + let event_id_hex = event_id_hex.to_owned(); + async move { + let id_bytes = nostr::EventId::from_hex(&event_id_hex) + .expect("event id") + .as_bytes() + .to_vec(); + state + .db + .get_event_by_id_for_event_write(community, &id_bytes) + .await + .expect("query event") + .expect("event persisted") + } + }; + let explicit = load_event(&explicit_event_id_hex).await; + let injected = load_event(&injected_event_id_hex).await; + + let tag_values = |stored: &buzz_core::StoredEvent, name: &str| -> Vec { + stored + .event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(name)) + .filter_map(|tag| tag.as_slice().get(1).cloned()) + .collect() + }; + let p_tag_targets = tag_values(&explicit, "p"); assert!( - p_tag_targets.contains(&author_hex.as_str()), + p_tag_targets.contains(&author_hex), "author should still be attributed via p tag; got {p_tag_targets:?}" ); assert!( - p_tag_targets.contains(&agent_hex.as_str()), + p_tag_targets.contains(&agent_hex), "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); + assert_eq!( + tag_values(&explicit, "buzz:workflow-owner"), + vec![author_hex.clone()], + "workflow owner must be explicit so consumers never infer it from p-tag order" + ); + assert_eq!( + tag_values(&explicit, "buzz:workflow-mention"), + vec![agent_hex.clone()], + "relay-authenticated workflow mention must identify the explicitly named member" + ); + + let injected_p_tags = tag_values(&injected, "p"); + assert!( + injected_p_tags.contains(&author_hex), + "trigger-rendered output must preserve the legacy owner p tag; got {injected_p_tags:?}" + ); + assert!( + injected_p_tags.contains(&agent_hex), + "trigger-rendered mention must preserve legacy mention/feed routing; got {injected_p_tags:?}" + ); + assert!( + tag_values(&injected, "buzz:workflow-mention").is_empty(), + "a mention introduced solely by trigger data must not receive owner-delegated authority" + ); } #[tokio::test] @@ -818,6 +1089,7 @@ mod integration_tests { community, &channel.id.to_string(), "root message", + "root message", &author_hex, None, ) @@ -830,6 +1102,7 @@ mod integration_tests { community, &channel.id.to_string(), "threaded reply", + "threaded reply", &author_hex, Some(&root_hex), ) @@ -844,7 +1117,7 @@ mod integration_tests { .to_vec(); let stored = state .db - .get_event_by_id(community, &reply_id_bytes) + .get_event_by_id_for_event_write(community, &reply_id_bytes) .await .expect("query reply") .expect("reply persisted"); @@ -972,6 +1245,7 @@ mod integration_tests { community, &channel_hex, "workflow reply", + "workflow reply", &author_hex, Some(&parent_hex), ) @@ -1012,7 +1286,7 @@ mod integration_tests { // reply→the immediate parent (matching the ingest resolver). let stored = state .db - .get_event_by_id(community, &reply_id_bytes) + .get_event_by_id_for_event_write(community, &reply_id_bytes) .await .expect("query reply") .expect("reply persisted"); @@ -1053,6 +1327,7 @@ mod integration_tests { community, &channel_hex, "workflow reply to root-only parent", + "workflow reply to root-only parent", &author_hex, Some(&root_only_parent_hex), ) @@ -1115,6 +1390,7 @@ mod integration_tests { community, &channel.id.to_string(), "orphan reply", + "orphan reply", &author_hex, Some(&unknown), ) diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs new file mode 100644 index 00000000000..29fcf991f8d --- /dev/null +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -0,0 +1,457 @@ +use std::{ + collections::BTreeMap, + io::{Read as _, Write as _}, + net::{TcpListener, TcpStream}, + process::{Child, Command, ExitStatus, Output, Stdio}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use serde_json::Value; + +use buzz_relay::lifecycle::StartupPhase; + +const VALID_RELAY_PRIVATE_KEY: &str = + "0000000000000000000000000000000000000000000000000000000000000001"; +const CHILD_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_CAPTURE_BYTES: u64 = 1024 * 1024; + +struct RelayProcess { + child: Option, + stdout: Option>, + stderr: Option>, + scratch_dir: std::path::PathBuf, +} + +struct CapturedStream { + retained: Vec, + total_bytes: u64, +} + +impl RelayProcess { + fn spawn(environment: &[(&str, &str)]) -> Self { + let scratch_dir = + std::env::temp_dir().join(format!("buzz-boot-lifecycle-{}", uuid::Uuid::new_v4())); + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-relay")); + command + .env_clear() + .env("RUST_BACKTRACE", "0") + .env("RUST_LOG", "buzz_relay=info") + .env("BUZZ_GIT_REPO_PATH", scratch_dir.join("repos")) + .env("BUZZ_GIT_PACK_CACHE_PATH", scratch_dir.join("pack-cache")) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (name, value) in environment { + command.env(name, value); + } + let mut child = command.spawn().expect("spawn buzz-relay child process"); + let stdout = child.stdout.take().expect("relay stdout pipe"); + let stderr = child.stderr.take().expect("relay stderr pipe"); + Self { + child: Some(child), + stdout: Some(thread::spawn(move || capture_stream(stdout))), + stderr: Some(thread::spawn(move || capture_stream(stderr))), + scratch_dir, + } + } + + fn try_wait(&mut self) -> Option { + self.child + .as_mut() + .expect("relay child") + .try_wait() + .expect("poll relay child") + } + + fn wait(mut self, timeout: Duration) -> Output { + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = self.try_wait() { + break status; + } + if Instant::now() >= deadline { + let child = self.child.as_mut().expect("relay child"); + let _ = child.kill(); + let _ = child.wait(); + panic!("buzz-relay child exceeded {timeout:?}"); + } + thread::sleep(Duration::from_millis(10)); + }; + self.child.take(); + let output = Output { + status, + stdout: join_capture(self.stdout.take(), "stdout"), + stderr: join_capture(self.stderr.take(), "stderr"), + }; + let _ = std::fs::remove_dir_all(&self.scratch_dir); + output + } + + fn terminate(mut self) -> Output { + self.child + .as_mut() + .expect("relay child") + .kill() + .expect("terminate exact relay child"); + self.wait(Duration::from_secs(2)) + } +} + +impl Drop for RelayProcess { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = std::fs::remove_dir_all(&self.scratch_dir); + } +} + +fn capture_stream(mut stream: impl std::io::Read) -> CapturedStream { + let mut retained = Vec::new(); + let mut total_bytes = 0_u64; + let mut chunk = [0_u8; 8192]; + loop { + let read = stream.read(&mut chunk).expect("read relay output pipe"); + if read == 0 { + break; + } + total_bytes = total_bytes.saturating_add(u64::try_from(read).expect("read size fits u64")); + let remaining = usize::try_from(MAX_CAPTURE_BYTES) + .expect("capture ceiling fits usize") + .saturating_sub(retained.len()); + retained.extend_from_slice(&chunk[..read.min(remaining)]); + } + CapturedStream { + retained, + total_bytes, + } +} + +fn join_capture(capture: Option>, stream: &str) -> Vec { + let capture = capture + .expect("relay capture thread") + .join() + .expect("relay capture thread must not panic"); + assert!( + capture.total_bytes <= MAX_CAPTURE_BYTES, + "relay {stream} exceeded {MAX_CAPTURE_BYTES} bytes: {}", + capture.total_bytes, + ); + capture.retained +} + +fn run_relay(environment: &[(&str, &str)]) -> Output { + RelayProcess::spawn(environment).wait(CHILD_TIMEOUT) +} + +fn scrape_metrics(port: u16) -> std::io::Result { + let address = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100))?; + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + stream.write_all(b"GET /metrics HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) +} + +fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + assert!( + process.try_wait().is_none(), + "relay exited before its metrics endpoint became usable" + ); + if let Ok(response) = scrape_metrics(port) { + if response.contains("buzz_audit_enabled") { + return response; + } + } + assert!( + Instant::now() < deadline, + "relay metrics did not become scrapeable within 8s" + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn assert_no_startup_lifecycle_metrics(scrape: &str) { + for line in scrape.lines() { + let Some(name) = line + .strip_prefix("# HELP ") + .or_else(|| line.strip_prefix("# TYPE ")) + .and_then(|rest| rest.split_ascii_whitespace().next()) + else { + continue; + }; + assert!( + !["startup", "boot", "lifecycle"] + .iter() + .any(|term| name.contains(term)) + && !StartupPhase::ALL + .iter() + .any(|phase| name.contains(phase.as_str())), + "logs-only lifecycle contract emitted metric family {name}" + ); + } +} + +fn lifecycle_events(output: &Output) -> Vec { + let mut events: Vec = output + .stdout + .split(|byte| *byte == b'\n') + .chain(output.stderr.split(|byte| *byte == b'\n')) + .filter_map(|line| serde_json::from_slice::(line).ok()) + .filter(|event| event["event_name"] == "buzz_process_lifecycle") + .collect(); + events.sort_by_key(|event| event["sequence"].as_u64()); + events +} + +fn lifecycle_events_from(bytes: &[u8]) -> Vec { + bytes + .split(|byte| *byte == b'\n') + .filter_map(|line| serde_json::from_slice::(line).ok()) + .filter(|event| event["event_name"] == "buzz_process_lifecycle") + .collect() +} + +fn assert_accounting(events: &[Value]) { + assert!(!events.is_empty(), "child emitted no lifecycle events"); + let boot_id = events[0]["process_boot_id"] + .as_str() + .expect("process_boot_id"); + let mut counts = BTreeMap::::new(); + for (index, event) in events.iter().enumerate() { + assert_eq!(event["schema_version"], 1); + assert_eq!(event["sequence"], u64::try_from(index + 1).unwrap()); + assert_eq!(event["process_boot_id"], boot_id); + assert_eq!(event["track"], "startup"); + let count = counts + .entry(event["phase"].as_str().expect("phase").to_owned()) + .or_default(); + match event["edge"].as_str() { + Some("started") => count.0 += 1, + Some("terminal") => count.1 += 1, + other => panic!("unexpected lifecycle edge: {other:?}"), + } + } + assert!( + counts + .values() + .all(|(started, terminal)| *started == 1 && *terminal == 1), + "every started phase must have one terminal: {counts:?}" + ); +} + +fn assert_terminal(events: &[Value], phase: &str, status: &str, reason: Option<&str>) { + let terminal = events + .iter() + .find(|event| event["phase"] == phase && event["edge"] == "terminal") + .unwrap_or_else(|| panic!("missing {phase} terminal")); + assert_eq!(terminal["status"], status); + match reason { + Some(reason) => assert_eq!(terminal["reason"], reason), + None => assert!(terminal["reason"].is_null()), + } +} + +fn phases(events: &[Value]) -> Vec<&str> { + events + .iter() + .filter(|event| event["edge"] == "started") + .map(|event| event["phase"].as_str().expect("phase")) + .collect() +} + +#[test] +fn invalid_config_terminalizes_at_main_even_with_logs_disabled() { + let output = run_relay(&[ + ("RUST_LOG", "off"), + ("BUZZ_BIND_ADDR", "not-a-socket-address"), + ]); + assert!(!output.status.success()); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_eq!( + phases(&events), + [ + "process_telemetry", + "crypto_init", + "tracing_init", + "config_load" + ] + ); + assert_terminal(&events, "config_load", "failed", Some("config_invalid")); + assert_terminal( + &events, + "process_telemetry", + "failed", + Some("config_invalid"), + ); + assert_eq!(lifecycle_events_from(&output.stderr), events); + assert!(lifecycle_events_from(&output.stdout).is_empty()); +} + +#[test] +#[cfg(unix)] +fn config_filesystem_failure_has_a_bounded_terminal() { + let output = run_relay(&[ + ("RUST_LOG", "off"), + ("BUZZ_GIT_REPO_PATH", "/dev/null/not-a-directory"), + ]); + assert!(!output.status.success()); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_terminal(&events, "config_load", "failed", Some("config_invalid")); + assert_terminal( + &events, + "process_telemetry", + "failed", + Some("config_invalid"), + ); +} + +#[test] +fn invalid_config_value_has_the_same_bounded_terminal() { + let output = run_relay(&[("RUST_LOG", "off"), ("BUZZ_DRAIN_JITTER_MS", "bogus")]); + assert!(!output.status.success()); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_terminal(&events, "config_load", "failed", Some("config_invalid")); + assert_terminal( + &events, + "process_telemetry", + "failed", + Some("config_invalid"), + ); +} + +#[test] +fn configured_otlp_terminalizes_tracing_before_a_later_failure() { + let output = run_relay(&[ + ("RUST_LOG", "off"), + ("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4317"), + ("BUZZ_BIND_ADDR", "not-a-socket-address"), + ]); + assert!(!output.status.success()); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_terminal(&events, "tracing_init", "succeeded", None); + assert_terminal(&events, "config_load", "failed", Some("config_invalid")); +} + +#[test] +fn missing_key_stops_before_metrics_bind() { + let output = run_relay(&[]); + assert!(!output.status.success()); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_eq!( + phases(&events), + [ + "process_telemetry", + "crypto_init", + "tracing_init", + "config_load", + "key_load" + ] + ); + assert_terminal(&events, "key_load", "failed", Some("missing")); + assert_terminal(&events, "process_telemetry", "failed", Some("missing")); +} + +#[test] +fn invalid_key_uses_a_bounded_reason_without_leaking_the_value() { + let secret = "private-key-material-that-must-not-appear"; + let output = run_relay(&[("BUZZ_RELAY_PRIVATE_KEY", secret)]); + assert!(!output.status.success()); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_terminal(&events, "key_load", "failed", Some("required_invalid")); + let combined = [output.stdout, output.stderr].concat(); + assert!(!String::from_utf8_lossy(&combined).contains(secret)); +} + +#[test] +fn occupied_metrics_port_has_a_typed_bind_terminal() { + let occupied = TcpListener::bind(("0.0.0.0", 0)).expect("bind occupied port"); + let port = occupied.local_addr().expect("occupied address").port(); + let port = port.to_string(); + let output = run_relay(&[ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &port), + ]); + assert!(!output.status.success()); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_terminal(&events, "metrics_bind", "failed", Some("bind")); + assert_terminal(&events, "process_telemetry", "failed", Some("bind")); +} + +#[test] +fn otlp_build_failure_is_degraded_without_leaking_endpoint_credentials() { + let secret = "telemetry-secret-marker"; + let endpoint = format!("https://telemetry-user:{secret}@["); + let fake_database = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake database"); + let database_url = format!( + "postgres://buzz@127.0.0.1:{}/buzz", + fake_database.local_addr().expect("database address").port() + ); + let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve metrics port"); + let port = reserved.local_addr().expect("metrics address").port(); + drop(reserved); + let port_value = port.to_string(); + let mut process = RelayProcess::spawn(&[ + ("OTEL_EXPORTER_OTLP_ENDPOINT", &endpoint), + ("RUST_LOG", "buzz_relay=warn"), + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &port_value), + ("DATABASE_URL", &database_url), + ]); + let scrape = wait_for_relay_metrics(&mut process, port); + assert_no_startup_lifecycle_metrics(&scrape); + let output = process.terminate(); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_terminal(&events, "tracing_init", "degraded", Some("exporter_build")); + assert_terminal( + &events, + "process_telemetry", + "degraded", + Some("exporter_build"), + ); + let combined = [output.stdout, output.stderr].concat(); + assert!(!String::from_utf8_lossy(&combined).contains(secret)); +} + +#[test] +fn successful_main_emits_complete_lifecycle_without_startup_metrics() { + let fake_database = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake database"); + let database_url = format!( + "postgres://buzz@127.0.0.1:{}/buzz", + fake_database.local_addr().expect("database address").port() + ); + let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve metrics port"); + let port = reserved.local_addr().expect("metrics address").port(); + drop(reserved); + let port_value = port.to_string(); + let mut process = RelayProcess::spawn(&[ + ("RUST_LOG", "off"), + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &port_value), + ("DATABASE_URL", &database_url), + ]); + let scrape = wait_for_relay_metrics(&mut process, port); + assert_no_startup_lifecycle_metrics(&scrape); + + let output = process.terminate(); + let events = lifecycle_events(&output); + assert_accounting(&events); + assert_terminal(&events, "crypto_init", "succeeded", None); + assert_terminal(&events, "tracing_init", "succeeded", None); + assert_terminal(&events, "config_load", "succeeded", None); + assert_terminal(&events, "key_load", "succeeded", None); + assert_terminal(&events, "metrics_bind", "succeeded", None); + assert_terminal(&events, "process_telemetry", "succeeded", None); +} diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73db..f43887b65b1 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -213,6 +213,21 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk Ok(()) } +/// Attach NIP-30 `["emoji", shortcode, url]` tags. +/// +/// Each element of `emoji_tags` must be a three-element vector whose first +/// entry is `"emoji"`. Entries that don't match this shape are silently +/// skipped so an unknown future shape never blocks a message send. +fn nip30_emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), SdkError> { + for et in emoji_tags { + if et.len() == 3 && et[0] == "emoji" { + let parts: Vec<&str> = et.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| SdkError::InvalidTag(e.to_string()))?); + } + } + Ok(()) +} + /// Build a stream message (kind 9). /// /// - `channel_id`: target channel UUID @@ -221,6 +236,7 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) /// - `broadcast`: if true, adds `["broadcast", "1"]` tag /// - `media_tags`: raw imeta tag vectors +/// - `emoji_tags`: NIP-30 `["emoji", shortcode, url]` tag vectors pub fn build_message( channel_id: Uuid, content: &str, @@ -228,6 +244,7 @@ pub fn build_message( mentions: &[&str], broadcast: bool, media_tags: &[Vec], + emoji_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; @@ -239,6 +256,7 @@ pub fn build_message( tags.push(tag(&["broadcast", "1"])?); } imeta_tags(media_tags, &mut tags)?; + nip30_emoji_tags(emoji_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content) .tags(tags) .allow_self_tagging()) @@ -2380,7 +2398,7 @@ mod tests { #[test] fn message_happy_path() { let cid = uuid(); - let ev = sign(build_message(cid, "hello", None, &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hello", None, &[], false, &[], &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 9); assert_eq!(ev.content, "hello"); assert!(has_tag(&ev, "h", &cid.to_string())); @@ -2394,7 +2412,8 @@ mod tests { let cid = uuid(); let sender = keys(); let self_pk = sender.public_key().to_hex(); - let builder = build_message(cid, "self-canary", None, &[&self_pk], false, &[]).unwrap(); + let builder = + build_message(cid, "self-canary", None, &[&self_pk], false, &[], &[]).unwrap(); let ev = builder.sign_with_keys(&sender).expect("sign"); assert!( has_tag(&ev, "p", &self_pk), @@ -2485,7 +2504,7 @@ mod tests { root_event_id: eid, parent_event_id: eid, }; - let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[], &[]).unwrap()); // Direct reply: only one e-tag with "reply" marker let e_tags: Vec<_> = ev .tags @@ -2508,7 +2527,7 @@ mod tests { root_event_id: root, parent_event_id: parent, }; - let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[], &[]).unwrap()); let e_tags: Vec<_> = ev .tags .iter() @@ -2526,7 +2545,7 @@ mod tests { #[test] fn message_broadcast_flag() { let cid = uuid(); - let ev = sign(build_message(cid, "hi", None, &[], true, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[], true, &[], &[]).unwrap()); assert!(has_tag(&ev, "broadcast", "1")); } @@ -2534,7 +2553,7 @@ mod tests { fn message_mentions_deduped() { let cid = uuid(); let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; - let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[], &[]).unwrap()); let p_tags = tag_values(&ev, "p"); assert_eq!(p_tags.len(), 1); } @@ -2555,7 +2574,7 @@ mod tests { }) .collect(); let refs: Vec<&str> = hexes.iter().map(|s| s.as_str()).collect(); - let result = build_message(cid, "hi", None, &refs, false, &[]); + let result = build_message(cid, "hi", None, &refs, false, &[], &[]); assert!(matches!(result, Err(SdkError::TooManyMentions))); } @@ -2563,7 +2582,7 @@ mod tests { fn message_content_too_large() { let cid = uuid(); let big = "x".repeat(64 * 1024 + 1); - let result = build_message(cid, &big, None, &[], false, &[]); + let result = build_message(cid, &big, None, &[], false, &[], &[]); assert!(matches!(result, Err(SdkError::ContentTooLarge { .. }))); } @@ -2571,7 +2590,91 @@ mod tests { fn message_max_content_ok() { let cid = uuid(); let max = "x".repeat(64 * 1024); - assert!(build_message(cid, &max, None, &[], false, &[]).is_ok()); + assert!(build_message(cid, &max, None, &[], false, &[], &[]).is_ok()); + } + + #[test] + fn message_emoji_tags_attached() { + let cid = uuid(); + let emoji_tags = vec![ + vec![ + "emoji".to_string(), + "wave".to_string(), + "https://example.com/wave.gif".to_string(), + ], + vec![ + "emoji".to_string(), + "party".to_string(), + "https://example.com/party.gif".to_string(), + ], + ]; + let ev = sign( + build_message( + cid, + ":wave: hey :party:", + None, + &[], + false, + &[], + &emoji_tags, + ) + .unwrap(), + ); + // Both emoji tags present + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "wave", "https://example.com/wave.gif"])); + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "party", "https://example.com/party.gif"])); + // kind 9 + assert_eq!(ev.kind.as_u16(), 9); + } + + #[test] + fn message_malformed_emoji_tag_silently_skipped() { + let cid = uuid(); + let emoji_tags = vec![ + // only 2 elements — invalid, must be skipped + vec!["emoji".to_string(), "wave".to_string()], + // wrong kind — must be skipped + vec![ + "imeta".to_string(), + "wave".to_string(), + "https://example.com/wave.gif".to_string(), + ], + // valid + vec![ + "emoji".to_string(), + "ok".to_string(), + "https://example.com/ok.gif".to_string(), + ], + ]; + let ev = sign(build_message(cid, "hi", None, &[], false, &[], &emoji_tags).unwrap()); + let emoji_count = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji")) + .count(); + assert_eq!(emoji_count, 1); + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "ok", "https://example.com/ok.gif"])); + } + + #[test] + fn message_empty_emoji_tags_slice_ok() { + let cid = uuid(); + let ev = sign(build_message(cid, "hello", None, &[], false, &[], &[]).unwrap()); + let emoji_count = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji")) + .count(); + assert_eq!(emoji_count, 0); } #[test] diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 2dff81bcf7a..f8a994bd0c5 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -165,22 +165,18 @@ pub fn compute_auth_tag( Ok(tag_json.to_string()) } -/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`. -/// -/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature -/// against the owner pubkey embedded in the tag. -/// -/// Returns the owner's [`PublicKey`] on success. -/// -/// # Errors -/// -/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count, -/// bad hex, self-attestation, or signature verification failure. -pub fn verify_auth_tag( - auth_tag_json: &str, - agent_pubkey: &PublicKey, -) -> Result { - let arr = parse_json_array(auth_tag_json)?; +struct ParsedAuthTag { + owner_pubkey_hex: String, + conditions: String, + sig_hex: String, +} + +/// Parse and validate the canonical wire representation shared by every +/// verification path. Keeping this check in one place prevents the crypto +/// verifier from accepting non-canonical values that the structural parser +/// rejects. +fn parse_auth_tag_fields(json_str: &str) -> Result { + let arr = parse_json_array(json_str)?; if arr.len() != 4 { return Err(SdkError::InvalidInput(format!( @@ -201,17 +197,41 @@ pub fn verify_auth_tag( let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) })?; + if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "owner pubkey must be 64 lowercase hex chars, got {:?}", + owner_pubkey_hex + ))); + } + let conditions = arr[2] .as_str() .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; + validate_conditions(conditions)?; + let sig_hex = arr[3] .as_str() .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; + if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "signature must be 128 lowercase hex chars, got length {}", + sig_hex.len() + ))); + } - let owner_pubkey = PublicKey::from_hex(owner_pubkey_hex) - .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; + Ok(ParsedAuthTag { + owner_pubkey_hex: owner_pubkey_hex.to_owned(), + conditions: conditions.to_owned(), + sig_hex: sig_hex.to_owned(), + }) +} - validate_conditions(conditions)?; +fn verify_parsed_auth_tag( + parsed: &ParsedAuthTag, + agent_pubkey: &PublicKey, +) -> Result { + let owner_pubkey = PublicKey::from_hex(&parsed.owner_pubkey_hex) + .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; if owner_pubkey == *agent_pubkey { return Err(SdkError::InvalidInput( @@ -219,10 +239,9 @@ pub fn verify_auth_tag( )); } - let sig = Signature::from_str(sig_hex) + let sig = Signature::from_str(&parsed.sig_hex) .map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?; - - let preimage = build_preimage(agent_pubkey, conditions); + let preimage = build_preimage(agent_pubkey, &parsed.conditions); let message = hash_preimage(&preimage); let xonly = owner_pubkey.xonly().map_err(|e| { @@ -235,6 +254,71 @@ pub fn verify_auth_tag( Ok(owner_pubkey) } +/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`. +/// +/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature +/// against the owner pubkey embedded in the tag. +/// +/// Returns the owner's [`PublicKey`] on success. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count, +/// bad hex, self-attestation, or signature verification failure. +pub fn verify_auth_tag( + auth_tag_json: &str, + agent_pubkey: &PublicKey, +) -> Result { + let parsed = parse_auth_tag_fields(auth_tag_json)?; + verify_parsed_auth_tag(&parsed, agent_pubkey) +} + +/// Verify a NIP-OA credential for relay admission at a signed auth event. +/// +/// This performs the normal signature and syntax checks, then evaluates every +/// `created_at<` and `created_at>` clause against the signed NIP-42, NIP-98, or +/// equivalent authentication event's `created_at`. Both operators are strict: +/// equality does not satisfy either clause. `kind=` clauses are deliberately +/// not evaluated at connection admission, matching NIP-AA's connection-wide +/// credential semantics. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] when the credential is invalid or the +/// signed authentication event does not satisfy a time condition. +pub fn verify_auth_tag_for_auth_event( + auth_tag_json: &str, + agent_pubkey: &PublicKey, + auth_event_created_at: u64, +) -> Result { + let parsed = parse_auth_tag_fields(auth_tag_json)?; + let owner_pubkey = verify_parsed_auth_tag(&parsed, agent_pubkey)?; + + for clause in parsed.conditions.split('&') { + let satisfied = if let Some(value) = clause.strip_prefix("created_at<") { + let bound = value + .parse::() + .map_err(|e| SdkError::InvalidInput(format!("invalid created_at< bound: {e}")))?; + auth_event_created_at < bound + } else if let Some(value) = clause.strip_prefix("created_at>") { + let bound = value + .parse::() + .map_err(|e| SdkError::InvalidInput(format!("invalid created_at> bound: {e}")))?; + auth_event_created_at > bound + } else { + continue; + }; + + if !satisfied { + return Err(SdkError::InvalidInput(format!( + "auth event created_at {auth_event_created_at} does not satisfy {clause}" + ))); + } + } + + Ok(owner_pubkey) +} + /// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the /// signature. /// @@ -250,52 +334,14 @@ pub fn verify_auth_tag( /// /// Returns [`SdkError::InvalidInput`] for any structural violation. pub fn parse_auth_tag(json_str: &str) -> Result { - let arr = parse_json_array(json_str)?; - - if arr.len() != 4 { - return Err(SdkError::InvalidInput(format!( - "auth tag must have 4 elements, got {}", - arr.len() - ))); - } - - let label = arr[0] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?; - if label != "auth" { - return Err(SdkError::InvalidInput(format!( - "first element must be \"auth\", got \"{label}\"" - ))); - } - - let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { - SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) - })?; - if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) { - return Err(SdkError::InvalidInput(format!( - "owner pubkey must be 64 hex chars, got {:?}", - owner_pubkey_hex - ))); - } - - let conditions = arr[2] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; - - validate_conditions(conditions)?; - - let sig_hex = arr[3] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; - if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) { - return Err(SdkError::InvalidInput(format!( - "signature must be 128 hex chars, got length {}", - sig_hex.len() - ))); - } - - Tag::parse(["auth", owner_pubkey_hex, conditions, sig_hex]) - .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) + let parsed = parse_auth_tag_fields(json_str)?; + Tag::parse([ + "auth", + &parsed.owner_pubkey_hex, + &parsed.conditions, + &parsed.sig_hex, + ]) + .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) } #[cfg(test)] @@ -412,6 +458,32 @@ mod tests { assert!(verify_auth_tag(&wrong_sig, &agent_pubkey).is_err()); } + #[test] + fn test_verify_rejects_noncanonical_hex() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("compute_auth_tag must succeed"); + let mut tag: Value = serde_json::from_str(&tag_json).expect("auth tag is valid JSON"); + + tag[1] = Value::String(owner_keys.public_key().to_hex().to_uppercase()); + assert!( + verify_auth_tag(&tag.to_string(), &agent_pubkey).is_err(), + "uppercase owner pubkeys must not reach the permissive hex decoder" + ); + + let mut tag: Value = serde_json::from_str(&tag_json).expect("auth tag is valid JSON"); + let uppercase_sig = tag[3] + .as_str() + .expect("signature is a string") + .to_uppercase(); + tag[3] = Value::String(uppercase_sig); + assert!( + verify_auth_tag(&tag.to_string(), &agent_pubkey).is_err(), + "uppercase signatures must not reach the permissive hex decoder" + ); + } + /// parse_auth_tag with a well-formed JSON array returns a Tag. #[test] fn test_parse_auth_tag_valid() { @@ -586,6 +658,40 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn auth_event_time_conditions_are_enforced_strictly() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + + let expired = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<1") + .expect("sign expired credential"); + assert!(verify_auth_tag_for_auth_event(&expired, &agent_pubkey, 200).is_err()); + + let not_yet_valid = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at>200") + .expect("sign future credential"); + assert!(verify_auth_tag_for_auth_event(¬_yet_valid, &agent_pubkey, 200).is_err()); + + let failed_second_clause = + compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<201&created_at<200") + .expect("sign credential with two upper bounds"); + assert!( + verify_auth_tag_for_auth_event(&failed_second_clause, &agent_pubkey, 200).is_err(), + "every clause must pass, even when an earlier clause succeeds" + ); + + let in_window = compute_auth_tag( + &owner_keys, + &agent_pubkey, + "kind=9&created_at>199&created_at<201", + ) + .expect("sign in-window credential"); + assert_eq!( + verify_auth_tag_for_auth_event(&in_window, &agent_pubkey, 200) + .expect("in-window credential passes"), + owner_keys.public_key() + ); + } + #[test] fn test_parse_rejects_invalid_conditions() { let bad = diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs similarity index 100% rename from crates/buzz-search/tests/fts_integration.rs rename to crates/buzz-search/tests/postgres_fts_integration.rs diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 079c27a913d..b8c7f4dd809 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -54,7 +54,10 @@ pub trait ActionSink: Send + Sync { /// carries its owning community so a workflow in community B posts into B /// even though the side effect has no inbound connection to bind. /// - `channel_id`: UUID string of the target channel - /// - `text`: message body (must not be empty/whitespace-only) + /// - `text`: rendered message body (must not be empty/whitespace-only) + /// - `authored_text`: the workflow owner's stored, unrendered step template; + /// consumers must use this rather than trigger-controlled rendered output + /// when attaching authority-bearing metadata /// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for /// the `p` attribution tag; the relay keypair signs the event) /// - `reply_to`: when `Some(event_id_hex)`, the message is posted as a @@ -67,6 +70,7 @@ pub trait ActionSink: Send + Sync { community_id: CommunityId, channel_id: &str, text: &str, + authored_text: &str, author_pubkey: &str, reply_to: Option<&str>, ) -> Pin> + Send + '_>>; diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index 5c712dcff7c..90a6a02e020 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -535,7 +535,7 @@ fn resolve_send_message_channel( /// `RequestApproval` returns `StepResult::Suspended` — the caller must /// persist state and stop the execution loop. pub async fn dispatch_action( - step_id: &str, + step: &Step, action: &ActionDef, engine: &WorkflowEngine, community_id: CommunityId, @@ -544,6 +544,8 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; + let step_id = &step.id; + // The workflow engine can outlive the serving request that spawned it. // Revalidate the durable community fence immediately before every external // side effect (message publish, webhook, delay/resume). A storage failure is @@ -622,12 +624,22 @@ pub async fn dispatch_action( "SendMessage → {channel_id}: {text}" ); + let authored_text = match &step.action { + SendMessage { text, .. } => text.as_str(), + _ => { + return Err(WorkflowError::InvalidDefinition( + "SendMessage: resolved action does not match its authored step" + .into(), + )); + } + }; let event_id = engine .action_sink()? .send_message( community_id, &channel_id, text, + authored_text, &owner_pubkey_hex, reply_to, ) @@ -1220,7 +1232,7 @@ async fn execute_steps( let dispatch_result = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), dispatch_action( - &step.id, + step, &resolved_action, engine, community_id, diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index bceb6d8bd8d..ee1c7467762 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -1047,7 +1047,7 @@ fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[test] diff --git a/deploy/charts/buzz-push-gateway/Chart.yaml b/deploy/charts/buzz-push-gateway/Chart.yaml index 4035fdce35b..fe302e58c28 100644 --- a/deploy/charts/buzz-push-gateway/Chart.yaml +++ b/deploy/charts/buzz-push-gateway/Chart.yaml @@ -3,6 +3,6 @@ apiVersion: v2 # branches (see docs/push-gateway-deployment.md, "Gateway chart release"). name: buzz-push-gateway description: Public capability-gated APNs last-hop gateway for Buzz -version: 0.1.0 +version: 0.2.0 appVersion: "0.1.0" type: application diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 20ce7567270..ecdc97582af 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -11,6 +11,9 @@ spec: template: metadata: labels: {{- include "push.runtimeLabels" . | nindent 8 }} + {{- with .Values.podAnnotations }} + annotations: {{- toYaml . | nindent 8 }} + {{- end }} spec: automountServiceAccountToken: false terminationGracePeriodSeconds: 60 diff --git a/deploy/charts/buzz-push-gateway/tests/datadog-values.yaml b/deploy/charts/buzz-push-gateway/tests/datadog-values.yaml new file mode 100644 index 00000000000..6a02fdca4c5 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/tests/datadog-values.yaml @@ -0,0 +1,30 @@ +# Render-only fixture proving Datadog Autodiscovery can scrape the private +# metrics listener without installing prometheus-operator CRDs. Deployment +# repositories must replace these illustrative selectors with their agent's +# actual namespace and pod labels. +podAnnotations: + ad.datadoghq.com/gateway.checks: | + { + "openmetrics": { + "init_config": {}, + "instances": [ + { + "openmetrics_endpoint": "http://%%host%%:8081/metrics", + "service": "buzz-push-gateway", + "namespace": "block.buzz_push_gateway", + "metrics": ["push_gateway_.*"], + "histogram_buckets_as_distributions": true, + "send_distribution_buckets": true, + "send_monotonic_counter": true, + "collect_counters_with_distributions": true + } + ] + } + } +networkPolicy: + monitoring: + enabled: true + namespaceSelector: + kubernetes.io/metadata.name: datadog + podSelector: + app.kubernetes.io/name: datadog-agent diff --git a/deploy/charts/buzz-push-gateway/tests/release-contract.sh b/deploy/charts/buzz-push-gateway/tests/release-contract.sh index 993c4c05369..eb445687fa9 100755 --- a/deploy/charts/buzz-push-gateway/tests/release-contract.sh +++ b/deploy/charts/buzz-push-gateway/tests/release-contract.sh @@ -3,10 +3,21 @@ set -euo pipefail env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml <<'RUBY' auto_text = File.read('.github/workflows/auto-tag-on-release-pr-merge.yml') publish_text = File.read('.github/workflows/push-gateway-helm-chart.yml') +deployment_text = File.read('docs/push-gateway-deployment.md') +chart = YAML.load_file('deploy/charts/buzz-push-gateway/Chart.yaml') # Parse first, then pin the tag producer and consumer strings whose agreement # makes this a reachable lane rather than an orphan publisher. YAML.load(auto_text) YAML.load(publish_text) +version = chart.fetch('version').to_s +raise "gateway chart version is not semver: #{version}" unless version.match?(/\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\z/) +workspace_package = File.read('Cargo.toml').match(/\[workspace\.package\](.*?)(?=\n\[|\z)/m) +raise "workspace package metadata is missing" unless workspace_package +binary_version = workspace_package[1].match(/^version\s*=\s*"([^"]+)"/)&.[](1) +raise "workspace package version is missing" unless binary_version +unless chart.fetch('appVersion').to_s == binary_version + raise "gateway chart appVersion does not match packaged binary #{binary_version}" +end [ 'push-chart-release/*)', 'VERSION="${BRANCH#push-chart-release/}"', @@ -26,4 +37,14 @@ end ].each do |needle| raise "missing gateway chart publisher contract: #{needle}" unless publish_text.include?(needle) end +[ + 'inspect and fetch the published chart version', + 'helm show chart oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z', + 'helm pull oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z', +].each do |needle| + raise "missing gateway chart retrieval guidance: #{needle}" unless deployment_text.include?(needle) +end +if deployment_text.include?('verify the immutable chart artifact') + raise 'gateway chart retrieval guidance overstates authenticity verification' +end RUBY diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 250955c5fc2..97568ba2d0c 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -1,25 +1,32 @@ #!/usr/bin/env bash set -euo pipefail -out=$(mktemp); production_out=$(mktemp) -trap 'rm -f "$out" "$production_out"' EXIT +out=$(mktemp); production_out=$(mktemp); route_out=$(mktemp); datadog_out=$(mktemp) +trap 'rm -f "$out" "$production_out" "$route_out" "$datadog_out" "${monitoring_out:-}"' EXIT # Defaults must lint and render without parameter injection. helm lint deploy/charts/buzz-push-gateway >/dev/null helm template push deploy/charts/buzz-push-gateway >"$out" -# Production values must attach push.buzz.xyz to an explicit Gateway. +# Production values support a platform-owned ingress without rendering an +# HTTPRoute. The environment-owned inputs remain mandatory. production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' - --set 'httpRoute.parentRefs[0].name=production-gateway' - --set 'httpRoute.parentRefs[0].namespace=gateway-system' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' ) helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out" +# Gateway API remains an explicit supported ingress mode when an operator opts +# in and supplies the environment-owned parent. +helm template push deploy/charts/buzz-push-gateway \ + --set httpRoute.enabled=true \ + --set 'httpRoute.parentRefs[0].name=production-gateway' \ + --set 'httpRoute.parentRefs[0].namespace=gateway-system' \ + >"$route_out" + env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ - - "$out" "$production_out" <<'RUBY' + - "$out" "$production_out" "$route_out" <<'RUBY' def assert!(condition, detail = "assertion failed") raise detail unless condition end @@ -38,6 +45,7 @@ migration = runtime.merge("app.kubernetes.io/component" => "migration") assert!(svc.dig("spec", "selector") == runtime) assert!(d.dig("spec", "selector", "matchLabels") == runtime) assert!(d.dig("spec", "template", "metadata", "labels") == runtime) +assert!(d.dig("spec", "template", "metadata", "annotations").nil?) assert!(j.dig("spec", "template", "metadata", "labels") == migration) assert!(svc.dig("spec", "selector") != j.dig("spec", "template", "metadata", "labels")) jenv = j.dig("spec", "template", "spec", "containers", 0, "env").to_h { |entry| [entry["name"], entry] } @@ -86,7 +94,11 @@ ingress_ports = np.dig("spec", "ingress") .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set assert!(ingress_ports == Set[8080], ingress_ports.inspect) production = YAML.load_stream(File.read(ARGV[1])).compact -route = production.find { |x| x["kind"] == "HTTPRoute" } +assert!(!production.any? { |x| x["kind"] == "HTTPRoute" }) +production_deployment = production.find { |x| x["kind"] == "Deployment" } +production_image = production_deployment.dig("spec", "template", "spec", "containers", 0, "image") +assert!(production_image == "ghcr.io/block/buzz-push-gateway@sha256:#{"a" * 64}", production_image.inspect) +route = YAML.load_stream(File.read(ARGV[2])).compact.find { |x| x["kind"] == "HTTPRoute" } assert!(!route.dig("spec", "parentRefs").empty?) assert!(route.dig("spec", "hostnames").include?("push.buzz.xyz")) RUBY @@ -107,7 +119,7 @@ if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=tr fi # The checked-in production contract is intentionally undeployable until CI or -# the release system supplies an immutable digest and environment-owned values. +# the release system supplies its environment-owned values. if helm template push deploy/charts/buzz-push-gateway -f deploy/charts/buzz-push-gateway/values-production.yaml >/dev/null 2>&1; then echo 'expected uninjected production values to fail' >&2 exit 1 @@ -115,7 +127,7 @@ fi # Enabling observability renders the scrape CRDs and adds a scoped 8081 ingress # keyed to the named monitoring source — never a blanket 8081 rule. -monitoring_out=$(mktemp); trap 'rm -f "$out" "$production_out" "$monitoring_out"' EXIT +monitoring_out=$(mktemp) helm template push deploy/charts/buzz-push-gateway \ --set podMonitor.enabled=true \ --set prometheusRule.enabled=true \ @@ -147,6 +159,44 @@ from = monitoring[0].fetch("from")[0] assert!(!from.dig("namespaceSelector", "matchLabels").empty? && !from.dig("podSelector", "matchLabels").empty?, from.inspect) RUBY +# Datadog discovers the same private endpoint from pod annotations and needs no +# prometheus-operator CRDs. Its agent ingress remains selector-scoped. +helm lint deploy/charts/buzz-push-gateway \ + -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml >/dev/null +helm template push deploy/charts/buzz-push-gateway \ + -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml \ + >"$datadog_out" + +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -rjson -ryaml -rset \ + - "$datadog_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +assert!(!xs.any? { |x| %w[PodMonitor PrometheusRule].include?(x["kind"]) }) +deployment = xs.find { |x| x["kind"] == "Deployment" } +raw_check = deployment.dig( + "spec", "template", "metadata", "annotations", + "ad.datadoghq.com/gateway.checks", +) +check = JSON.parse(raw_check) +instance = check.dig("openmetrics", "instances", 0) +assert!(instance["openmetrics_endpoint"] == "http://%%host%%:8081/metrics", instance.inspect) +assert!(instance["metrics"] == ["push_gateway_.*"], instance.inspect) + +np = xs.find do |x| + x["kind"] == "NetworkPolicy" && x.dig("metadata", "name") == "push-buzz-push-gateway" +end +monitoring = np.dig("spec", "ingress").select do |rule| + rule.fetch("ports", []).map { |port| port["port"] }.to_set == Set[8081] +end +assert!(monitoring.length == 1, "exactly one scoped Datadog 8081 ingress rule") +from = monitoring[0].fetch("from")[0] +assert!(!from.dig("namespaceSelector", "matchLabels").empty?, from.inspect) +assert!(!from.dig("podSelector", "matchLabels").empty?, from.inspect) +RUBY + # Negative: monitoring enabled with default empty selectors must fail (would # otherwise render a blanket 8081 rule matching all namespaces/pods). if helm template push deploy/charts/buzz-push-gateway \ @@ -156,9 +206,8 @@ if helm template push deploy/charts/buzz-push-gateway \ exit 1 fi -# Negative: scrape flags must be coupled. PodMonitor without ingress = an -# unreachable scraper; ingress without a PodMonitor = an open hole with no -# scraper. Both mismatches must fail schema validation. +# Negative: PodMonitor without ingress is an unreachable scraper and must fail. +# Scoped ingress without PodMonitor is valid for annotation-discovered agents. if helm template push deploy/charts/buzz-push-gateway \ --set podMonitor.enabled=true \ --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ @@ -167,14 +216,6 @@ if helm template push deploy/charts/buzz-push-gateway \ echo 'expected podMonitor.enabled without monitoring ingress to fail' >&2 exit 1 fi -if helm template push deploy/charts/buzz-push-gateway \ - --set networkPolicy.monitoring.enabled=true \ - --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ - --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ - >/dev/null 2>&1; then - echo 'expected monitoring ingress without podMonitor.enabled to fail' >&2 - exit 1 -fi # Negative: retry-ratio threshold is a fraction; a value > 1 must fail schema. if helm template push deploy/charts/buzz-push-gateway \ diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 8017f6bacdb..85dd8af1a8c 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -7,7 +7,9 @@ profiles: dogfood: appAttestAppId: "" httpRoute: - enabled: true + # Keep disabled when the platform already routes push.buzz.xyz to this + # Service. Gateway API users enable it and inject an explicit parentRef. + enabled: false parentRefs: [] hostnames: - push.buzz.xyz diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 29eafa22c8d..1339b777e50 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -32,6 +32,12 @@ } }, "apnsKey": false, + "podAnnotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "httpRoute": { "type": "object", "required": [ @@ -313,7 +319,7 @@ ], "allOf": [ { - "$comment": "Scraping opt-in is coupled: a PodMonitor and its scoped 8081 ingress must be enabled together, so we never render a scraper that cannot reach the port nor an ingress hole with no scraper.", + "$comment": "A PodMonitor requires scoped 8081 ingress. External scrapers such as Datadog may enable that ingress without rendering a PodMonitor.", "if": { "properties": { "podMonitor": { @@ -355,49 +361,6 @@ "networkPolicy" ] } - }, - { - "if": { - "properties": { - "networkPolicy": { - "properties": { - "monitoring": { - "properties": { - "enabled": { - "const": true - } - }, - "required": [ - "enabled" - ] - } - }, - "required": [ - "monitoring" - ] - } - }, - "required": [ - "networkPolicy" - ] - }, - "then": { - "properties": { - "podMonitor": { - "properties": { - "enabled": { - "const": true - } - }, - "required": [ - "enabled" - ] - } - }, - "required": [ - "podMonitor" - ] - } } ] } diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 1f1e90cbb08..245d1a682ec 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -34,9 +34,11 @@ appAttestRoot: secretKey: app-attest-root.pem service: port: 8080 +podAnnotations: {} httpRoute: # Disabled by default so a generic install cannot claim an unattached route. - # Production enables this with an explicit Gateway parentRef. + # Enable only when this chart owns a Gateway API route. Environments with an + # existing ingress or service mesh route should keep this disabled. enabled: false parentRefs: [] hostnames: [push.buzz.xyz] @@ -60,8 +62,9 @@ networkPolicy: podSelector: k8s-app: kube-dns # Scoped ingress to the private metrics port (8081). Off by default so 8081 - # has no pod ingress at all; enable only alongside podMonitor and name the - # scraper's namespace/pod so reachability stays narrow. + # has no pod ingress at all; enable alongside podMonitor or an external + # annotation-discovered scraper and name its namespace/pod so reachability + # stays narrow. monitoring: enabled: false namespaceSelector: {} diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 8a4b0c6d665..5e778279130 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -115,6 +115,79 @@ disables that probe through `relay.extraEnv`, `/_readiness` does not test object storage; configuration is still parsed strictly, but reachability and addressing errors surface on the first storage operation. +### Early-startup telemetry contract + +`buzz_process_lifecycle` JSON records are the authoritative history for the +fixed phases `crypto_init`, `tracing_init`, `config_load`, `key_load`, and +`metrics_bind`, plus the aggregate `process_telemetry` result. They use bounded +status/reason values and never contain raw configuration, keys, URLs, or errors. +These phases intentionally do not emit metrics. Most run before the Prometheus +exporter exists, and one uniform log-only contract preserves every phase's real +event time and failure without assigning an eventual scrape time to earlier work. + +### Readiness telemetry contract + +Only requests served by the private health listener (`BUZZ_HEALTH_PORT`) emit +rollout readiness telemetry. The compatibility `/_readiness` route on the public +app listener returns health but does not change these metrics. + +| Metric | Type | Labels | +|--------|------|--------| +| `buzz_readiness_checks_total` | counter | `reason` from the closed readiness-reason set | +| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` | +| `buzz_readiness_check_duration_seconds` | histogram | `check` only | +| `buzz_readiness_state` | gauge | `check` only; latest publishable generation | + +The schema has a ceiling of 99 raw Prometheus series per pod: 12 overall +reasons, 11 valid dependency/outcome pairs, 72 histogram series, and 4 gauges. +Do not add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, +user, community, pubkey, header, query, or other request-controlled labels. +Shutdown without dependency evaluation increments only +`buzz_readiness_checks_total{reason="shutting_down"}` and sets the overall +state to zero; it does not fabricate dependency failures or latency samples. + +### Operation-aware database pool acquisition contract + +The operation-aware families separate three questions: who is waiting now, +how completed/abandoned attempts ended, and how long checkout waits took. +Outcome remains on the terminal counter for historical deployment comparison; +it is intentionally absent from the expensive duration histogram. + +These families cover the explicitly routed deployment-critical operations +listed below; they are not a count of every SQLx checkout in Buzz. In +particular, a zero operation waiter does not prove that the shared SQLx pool +has no uninstrumented waiter. Interpret it beside the pool active, idle, and +maximum gauges when diagnosing total capacity pressure. + +| Metric | Type | Labels | +|--------|------|--------| +| `buzz_db_pool_acquire_duration_seconds` | histogram | `pool_role`, `operation` | +| `buzz_db_pool_acquire_attempts_total` | counter | `pool_role`, `operation`, `outcome` | +| `buzz_db_pool_waiters` | gauge | `pool_role`, `operation`; tracked operations only, periodically refreshed including zero | + +Outcomes are `success`, `timeout`, `error`, and `cancelled`. Operations are +`bootstrap`, `readiness`, `tenant_resolution`, `authentication`, +`authorization`, `subscription_history`, `event_write`, and `maintenance`. +Only the following eleven pairs are valid: + +```text +writer/bootstrap reader/bootstrap +writer/readiness +writer/tenant_resolution +writer/authentication +writer/authorization reader/authorization +writer/subscription_history reader/subscription_history +writer/event_write +writer/maintenance +``` + +Nine finite checkout buckets plus `+Inf`, sum, and count yield 12 histogram +series per valid pair. The new contract therefore has a hard ceiling of 187 +raw Prometheus series per pod: `11 × (12 + 4 + 1)`. The two legacy acquisition +families remain temporarily for dashboard compatibility and are not part of +that new-family budget. No `other` operation or request-controlled/sensitive +label is valid. + ## Relay Pod extensions The chart exposes narrow extension points for init containers, volumes, relay diff --git a/desktop/.env.e2e b/desktop/.env.e2e new file mode 100644 index 00000000000..a323e18f6c7 --- /dev/null +++ b/desktop/.env.e2e @@ -0,0 +1 @@ +VITE_BUZZ_BESTIE=1 diff --git a/desktop/package.json b/desktop/package.json index 1e93fd76a85..3d6024e1053 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc && node ./scripts/build-protected-feature-artifacts.mjs", "build:e2e": "tsc && vite build --mode e2e", "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", @@ -14,15 +14,15 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\"", "preview": "vite preview", - "tauri": "tauri", + "tauri": "node ./scripts/tauri-command.mjs", "test:e2e": "pnpm build:e2e && playwright test", "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", "test:e2e:release-smoke": "pnpm build:e2e && playwright test --config=playwright.release-smoke.config.ts", "test:e2e:report": "playwright show-report", - "tauri:build": "tauri build" + "tauri:build": "node ./scripts/tauri-command.mjs build" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -63,6 +63,7 @@ "@tiptap/starter-kit": "^3.22.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", @@ -73,6 +74,7 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^19.1.0", + "react-day-picker": "^10.0.1", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c5bdbc5666..1af27b58ef2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,12 +20,14 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", "**/tooltip-semantics.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", + "**/exact-key-profile.spec.ts", "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", @@ -39,6 +41,7 @@ export default defineConfig({ "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", + "**/bestie.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/message-copy-link.spec.ts", "**/custom-emoji.spec.ts", @@ -49,6 +52,7 @@ export default defineConfig({ "**/channel-controls.spec.ts", "**/channel-activity-popover.spec.ts", "**/active-turn-resilience.spec.ts", + "**/agent-control-regressions.spec.ts", "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", @@ -57,6 +61,7 @@ export default defineConfig({ "**/welcome-agent-modal-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", "**/voice-settings.spec.ts", + "**/voice-note.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", @@ -72,6 +77,8 @@ export default defineConfig({ "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/mention-spacing.spec.ts", + "**/cloud-provenance.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", @@ -108,6 +115,7 @@ export default defineConfig({ "**/channel-head-restart.spec.ts", "**/live-broadcast-reply-timeline.spec.ts", "**/markdown-parse-cache.spec.ts", + "**/markdown-tables.spec.ts", "**/overscroll-boundary.spec.ts", "**/terminal-wheel.spec.ts", "**/cold-switch-longtask.perf.ts", @@ -147,6 +155,7 @@ export default defineConfig({ "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", "**/settings-section-layout.spec.ts", + "**/experimental-features.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", @@ -171,6 +180,7 @@ export default defineConfig({ name: "integration", testMatch: [ "**/agents.spec.ts", + "**/agent-availability.spec.ts", "**/agent-snapshot-recipient.spec.ts", "**/onboarding.spec.ts", "**/stream.spec.ts", diff --git a/desktop/public/harness-logos/CREDITS.md b/desktop/public/harness-logos/CREDITS.md index 716c43e1ae3..dee5aa257e9 100644 --- a/desktop/public/harness-logos/CREDITS.md +++ b/desktop/public/harness-logos/CREDITS.md @@ -13,6 +13,7 @@ license permits redistribution. | `hermes.png` | [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) | `6ad632b` | MIT © 2025 Nous Research | `website/static/img/logo.png` | Cropped the baked-in border frame, padded to square, resized to 64×64, quantised to a 16-colour palette | | `openclaw.svg` | [openclaw/openclaw](https://github.com/openclaw/openclaw) | `b06f40a` | MIT © 2026 OpenClaw Foundation | `ui/public/favicon.svg` | Removed the SMIL animation elements (renders the upstream rest pose statically — verified pixel-identical to the upstream frame at t=0); minified paths | | `omp.svg` | [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) | `667111575ebba136dadfd6989379e7f67e0d40d9` | MIT © 2025 Mario Zechner; © 2025–2026 Can Bölük | `assets/icon.svg` | None | +| `pi.svg` | [earendil-works/pi-website](https://github.com/earendil-works/pi-website) | `2f5e410b97474d0a34ec2500aa1aa58d6c3f992c` | MIT © 2026 Earendil Inc. and contributors | `src/favicon.svg` | None | | `kimi.png` | [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) | `4a550effdfcb29a25a5d325bf935296cc50cd417` | Apache-2.0; NOTICE: Kimi Code CLI © 2025 Moonshot AI | `web/public/logo.png` | None | | `grok.svg` | [SpaceXAI brand guidelines](https://x.ai/legal/brand-guidelines) | Retrieved 2026-07-25 | xAI Brand Guidelines: marks may be used to accurately refer to xAI or its services; logos must be used exactly as provided | `SpaceXAI_Grok_Assets.zip` → `Grok_Logomark_Dark.svg` | None | diff --git a/desktop/public/harness-logos/pi.svg b/desktop/public/harness-logos/pi.svg new file mode 100644 index 00000000000..c28d6242332 --- /dev/null +++ b/desktop/public/harness-logos/pi.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/desktop/scripts/build-protected-feature-artifacts.mjs b/desktop/scripts/build-protected-feature-artifacts.mjs new file mode 100644 index 00000000000..3de4830ceeb --- /dev/null +++ b/desktop/scripts/build-protected-feature-artifacts.mjs @@ -0,0 +1,151 @@ +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const vitePackageJsonPath = fileURLToPath( + import.meta.resolve("vite/package.json"), +); +const vitePackage = JSON.parse(readFileSync(vitePackageJsonPath, "utf8")); +const viteEntrypoint = path.resolve( + path.dirname(vitePackageJsonPath), + vitePackage.bin.vite, +); + +function buildVariant({ internal, output }) { + const env = { + ...process.env, + // Pin both children explicitly. Deleting the OSS value lets Vite reload + // `=1` from .env.local or a mode-specific env file. + VITE_BUZZ_BESTIE: internal ? "1" : "0", + }; + + const result = spawnSync( + process.execPath, + [viteEntrypoint, "build", "--outDir", output, "--emptyOutDir"], + { + cwd: desktopRoot, + env, + stdio: "inherit", + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `${internal ? "internal" : "OSS"} desktop build failed with status ${result.status}`, + ); + } +} + +function emittedText(root) { + const chunks = []; + const visit = (candidate) => { + const stat = statSync(candidate); + if (stat.isDirectory()) { + for (const child of readdirSync(candidate)) { + visit(path.join(candidate, child)); + } + return; + } + if (/\.(?:css|html|js|json)$/u.test(candidate)) { + chunks.push(readFileSync(candidate, "utf8")); + } + }; + visit(root); + return chunks.join("\n"); +} + +export function assertArtifactContract({ ossOutput, internalOutput }) { + const ossText = emittedText(ossOutput); + const internalText = emittedText(internalOutput); + const protectedContent = /\bbestie\b|chief of staff|builtin:bestie/iu; + const internalManifestMarker = + "Try a personal agent that is always close at hand"; + + if (protectedContent.test(ossText)) { + throw new Error( + "Official OSS desktop artifact contains protected Bestie/Chief content", + ); + } + if (!internalText.includes(internalManifestMarker)) { + throw new Error( + "Protected internal desktop artifact is missing the Bestie manifest", + ); + } +} + +/** Resolve the requested output with the same precedence used by Vite config. */ +export function selectInternalVariant({ processEnv, modeEnv }) { + return (processEnv.VITE_BUZZ_BESTIE ?? modeEnv.VITE_BUZZ_BESTIE) === "1"; +} + +/** Build and inspect both graphs, leaving the requested variant in dist. */ +export function buildArtifactMatrix({ + selectedInternalVariant, + selectedOutput, + alternateOutput, + build = buildVariant, +}) { + // Build the unselected variant outside dist first, then leave the requested + // variant in dist for Vite/Tauri's ordinary packaging contract. + build({ + internal: !selectedInternalVariant, + output: alternateOutput, + }); + build({ + internal: selectedInternalVariant, + output: selectedOutput, + }); + + assertArtifactContract({ + ossOutput: selectedInternalVariant ? alternateOutput : selectedOutput, + internalOutput: selectedInternalVariant ? selectedOutput : alternateOutput, + }); +} + +function main() { + const selectedInternalVariant = selectInternalVariant({ + processEnv: process.env, + modeEnv: loadEnv("production", desktopRoot, ""), + }); + const scratchRoot = mkdtempSync( + path.join(tmpdir(), "buzz-protected-feature-artifacts-"), + ); + const selectedOutput = process.env.BUZZ_PROTECTED_BUILD_OUTPUT + ? path.resolve(process.env.BUZZ_PROTECTED_BUILD_OUTPUT) + : path.join(desktopRoot, "dist"); + const alternateOutput = path.join(scratchRoot, "alternate"); + + try { + buildArtifactMatrix({ + selectedInternalVariant, + selectedOutput, + alternateOutput, + }); + } finally { + rmSync(scratchRoot, { recursive: true, force: true }); + } + + console.log( + `Protected feature artifact matrix passed; dist contains the ${selectedInternalVariant ? "internal" : "OSS"} variant.`, + ); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index bfe4fcc8570..bc2d179695b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -1,61 +1,21 @@ +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-policy.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); +const scriptPath = realpathSync(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); -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"]), - maxLines: MAX_LINES, - }, - { - root: "src/features", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/api", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/context", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/lib", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/ui", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/styles", - extensions: new Set([".css"]), - maxLines: MAX_LINES, - }, -]; - -await runFileSizeCheck({ +export const policy = { projectRoot, rules, label: "Desktop", -}); +}; + +if ( + process.argv[1] && + realpathSync(path.resolve(process.argv[1])) === scriptPath +) { + await runFileSizeCheck(policy); +} diff --git a/desktop/scripts/demo-build-config.mjs b/desktop/scripts/demo-build-config.mjs new file mode 100644 index 00000000000..fd5c9ed2a1c --- /dev/null +++ b/desktop/scripts/demo-build-config.mjs @@ -0,0 +1,94 @@ +import { randomBytes } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PRODUCTION_IDENTIFIER = "xyz.block.buzz.app"; +// The build ID suffix is 17 characters including its separator, and the Rust +// build contract caps the complete demo slug at 48 ASCII bytes. +const MAX_DEMO_SLUG_LENGTH = 48; +const DEMO_BUILD_ID_SUFFIX_LENGTH = 17; +const MAX_DEMO_NAME_LENGTH = MAX_DEMO_SLUG_LENGTH - DEMO_BUILD_ID_SUFFIX_LENGTH; + +export const productionBuildIdentity = Object.freeze({ + productName: "Buzz", + identifier: PRODUCTION_IDENTIFIER, + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", +}); + +export function demoBuildConfig( + rawName, + buildId = randomBytes(8).toString("hex"), +) { + if (typeof rawName !== "string") throw new Error("Demo name must be text"); + const name = rawName.trim().replace(/\s+/g, " "); + if (!name) throw new Error("Demo name must not be empty"); + if (name.length > MAX_DEMO_NAME_LENGTH) { + throw new Error( + `Demo name must be at most ${MAX_DEMO_NAME_LENGTH} characters`, + ); + } + if (!/^[A-Za-z0-9][A-Za-z0-9 -]*$/.test(name)) { + throw new Error( + "Demo name may contain ASCII letters, numbers, spaces, and hyphens only", + ); + } + + if (!/^[a-f0-9]{16}$/.test(buildId)) { + throw new Error( + "Demo build ID must be sixteen lowercase hexadecimal characters", + ); + } + + const readableSlug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const slug = `${readableSlug}-${buildId}`; + const productName = `Buzz ${name}`; + return { + name, + slug, + productName, + dmgVolumeName: productName, + dmgFileStem: productName.replace(/ /g, "_"), + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + appDataIdentity: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName, + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const [name, outputPath, buildId] = process.argv.slice(2); + if (!outputPath) { + console.error( + "Usage: demo-build-config.mjs ", + ); + process.exit(2); + } + try { + const config = demoBuildConfig(name, buildId); + writeFileSync( + outputPath, + `${JSON.stringify(config.tauriConfig, null, 2)}\n`, + ); + console.log(JSON.stringify(config)); + } catch (error) { + console.error(`Invalid demo build: ${error.message}`); + process.exit(1); + } +} diff --git a/desktop/scripts/demo-build-config.test.mjs b/desktop/scripts/demo-build-config.test.mjs new file mode 100644 index 00000000000..db2ba568c7b --- /dev/null +++ b/desktop/scripts/demo-build-config.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + demoBuildConfig, + productionBuildIdentity, +} from "./demo-build-config.mjs"; + +const expected = (name, slug) => ({ + name, + slug, + productName: `Buzz ${name}`, + dmgVolumeName: `Buzz ${name}`, + dmgFileStem: `Buzz_${name.replace(/ /g, "_")}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + appDataIdentity: `xyz.block.buzz.app.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName: `Buzz ${name}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, +}); + +test("production identity remains unchanged", () => { + assert.deepEqual(productionBuildIdentity, { + productName: "Buzz", + identifier: "xyz.block.buzz.app", + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", + }); +}); + +test("two demo names produce complete, distinct identities", () => { + const board = demoBuildConfig("Workstream Board", "27a4294c27a4294c"); + const interests = demoBuildConfig("Interests Demo", "deb5339adeb5339a"); + assert.deepEqual( + board, + expected("Workstream Board", "workstream-board-27a4294c27a4294c"), + ); + assert.deepEqual( + interests, + expected("Interests Demo", "interests-demo-deb5339adeb5339a"), + ); + for (const key of [ + "productName", + "dmgVolumeName", + "dmgFileStem", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(board[key], interests[key], key); + assert.notEqual(board[key], productionBuildIdentity[key], key); + } +}); + +test("normalized spelling aliases retain distinct runtime identities", () => { + for (const [leftName, rightName] of [ + ["A B", "A-B"], + ["Demo", "demo"], + ["Workstream Board", "WORKSTREAM BOARD"], + ]) { + const left = demoBuildConfig(leftName, "1111111111111111"); + const right = demoBuildConfig(rightName, "2222222222222222"); + assert.notEqual(left.slug, right.slug); + for (const key of [ + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual( + left[key], + right[key], + `${leftName}/${rightName}: ${key}`, + ); + } + } +}); + +test("the same display name gets a distinct identity for each build", () => { + const first = demoBuildConfig("Demo", "1111111111111111"); + const second = demoBuildConfig("Demo", "2222222222222222"); + assert.equal(first.productName, second.productName); + assert.equal(first.dmgFileStem, second.dmgFileStem); + for (const key of [ + "slug", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + +test("whitespace normalization preserves deterministic identity", () => { + assert.deepEqual( + demoBuildConfig(" Workstream Board ", "27a4294c27a4294c"), + demoBuildConfig("Workstream Board", "27a4294c27a4294c"), + ); +}); + +test("maximum-length name produces a Rust-valid 48-byte slug", () => { + const config = demoBuildConfig("x".repeat(31), "1234567812345678"); + assert.equal(config.slug.length, 48); + assert.match(config.slug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); +}); + +for (const name of [ + "", + " ", + "Workstream/Board", + "Workstream_Board", + "équipe", + "x".repeat(32), +]) { + test(`rejects unusable name ${JSON.stringify(name)}`, () => + assert.throws(() => demoBuildConfig(name, "1234567812345678"))); +} diff --git a/desktop/scripts/file-size-policy.mjs b/desktop/scripts/file-size-policy.mjs new file mode 100644 index 00000000000..5728b9187a6 --- /dev/null +++ b/desktop/scripts/file-size-policy.mjs @@ -0,0 +1,53 @@ +const DESKTOP_FRONTEND_MAX_LINES = 1200; +const DESKTOP_RUST_MAX_LINES = 1500; + +export const rules = [ + { + root: "src-tauri/src", + extensions: new Set([".rs"]), + maxLines: DESKTOP_RUST_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: DESKTOP_RUST_MAX_LINES, + }, + { + root: "src/app", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/features", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/api", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/context", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/lib", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/ui", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/styles", + extensions: new Set([".css"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, +]; diff --git a/desktop/scripts/package-macos-dmg.sh b/desktop/scripts/package-macos-dmg.sh new file mode 100755 index 00000000000..7ecaf9502e8 --- /dev/null +++ b/desktop/scripts/package-macos-dmg.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Build a drag-to-Applications DMG without requiring a GUI login session. +# Finder styling is optional; the disk image itself is always authoritative. + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +app_path="$1" +out_dmg="$2" +app_name="$(basename "$app_path")" +volume_name="${VOL_NAME:-Buzz}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +background="$script_dir/../src-tauri/icons/dmg-background.png" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-dmg.XXXXXX")" +source_dir="$work_dir/source" +rw_dmg="$work_dir/read-write.dmg" +mount_point="$work_dir/mount" +applescript="$work_dir/style.applescript" +device="" + +finish() { + local status="$?" + trap - EXIT + if [[ -n "$device" ]]; then + hdiutil detach "$device" >/dev/null 2>&1 || true + hdiutil detach -force "$device" >/dev/null 2>&1 || true + fi + rm -rf "$work_dir" + exit "$status" +} +trap finish EXIT + +[[ -d "$app_path" ]] || { echo "App bundle not found: $app_path" >&2; exit 1; } +[[ -f "$background" ]] || { echo "DMG background not found: $background" >&2; exit 1; } + +mkdir -p "$(dirname "$out_dmg")" "$source_dir/.background" "$mount_point" +ditto "$app_path" "$source_dir/$app_name" +ln -s /Applications "$source_dir/Applications" +cp "$background" "$source_dir/.background/background.png" + +rm -f "$rw_dmg" "$out_dmg" +hdiutil create -volname "$volume_name" -srcfolder "$source_dir" \ + -format UDRW -ov "$rw_dmg" >/dev/null + +attach_output="$(hdiutil attach -readwrite -noverify -noautoopen -nobrowse \ + -mountpoint "$mount_point" "$rw_dmg")" +device="$(printf '%s\n' "$attach_output" | awk '/^\/dev\// { print $1; exit }')" +[[ -n "$device" ]] || { echo "Failed to attach writable DMG" >&2; exit 1; } + +detach() { + local attempt + for attempt in 1 2 3 4 5; do + if hdiutil detach "$device" >/dev/null 2>&1; then + device="" + return 0 + fi + sleep 1 + done + hdiutil detach -force "$device" >/dev/null + device="" +} + +if command -v SetFile >/dev/null 2>&1; then + SetFile -a V "$mount_point/.background" || true + icon="$mount_point/$app_name/Contents/Resources/icon.icns" + if [[ -f "$icon" ]]; then + cp "$icon" "$mount_point/.VolumeIcon.icns" || true + SetFile -c icnC "$mount_point/.VolumeIcon.icns" || true + SetFile -a C "$mount_point" || true + fi +fi + +cat >"$applescript" <<'APPLESCRIPT' +on run argv + set mountPath to item 1 of argv + set appName to item 2 of argv + tell application "Finder" + set rootFolder to POSIX file mountPath as alias + open rootFolder + set imageWindow to container window of rootFolder + set current view of imageWindow to icon view + set toolbar visible of imageWindow to false + set statusbar visible of imageWindow to false + set bounds of imageWindow to {200, 120, 860, 652} + set viewOptions to icon view options of imageWindow + set arrangement of viewOptions to not arranged + set icon size of viewOptions to 128 + set text size of viewOptions to 14 + set background picture of viewOptions to file ".background:background.png" of rootFolder + set position of item appName of rootFolder to {191, 330} + set position of item "Applications" of rootFolder to {469, 330} + set extension hidden of item appName of rootFolder to true + delay 1 + close imageWindow + end tell +end run +APPLESCRIPT + +style_with_finder() { + local child elapsed=0 + /usr/bin/osascript "$applescript" "$mount_point" "$app_name" & + child=$! + while kill -0 "$child" 2>/dev/null; do + if (( elapsed >= 100 )); then + echo "Finder styling timed out; continuing without it" >&2 + kill "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + return 124 + fi + sleep 0.1 + elapsed=$((elapsed + 1)) + done + wait "$child" +} + +if ! style_with_finder; then + echo "Finder styling unavailable; continuing without it" >&2 +fi + +sync +detach +hdiutil convert "$rw_dmg" -format UDZO -imagekey zlib-level=9 \ + -o "$out_dmg" >/dev/null +printf 'DMG ready: %s\n' "$out_dmg" diff --git a/desktop/scripts/tauri-command.mjs b/desktop/scripts/tauri-command.mjs new file mode 100644 index 00000000000..dc1d8691e96 --- /dev/null +++ b/desktop/scripts/tauri-command.mjs @@ -0,0 +1,63 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const tauriPackageJsonPath = fileURLToPath( + import.meta.resolve("@tauri-apps/cli/package.json"), +); +const tauriPackage = JSON.parse(readFileSync(tauriPackageJsonPath, "utf8")); +const defaultTauriEntrypoint = path.resolve( + path.dirname(tauriPackageJsonPath), + tauriPackage.bin.tauri, +); + +function runTauri(args, options = {}) { + const entrypoint = + process.env.BUZZ_TAURI_CLI_ENTRYPOINT ?? defaultTauriEntrypoint; + const result = spawnSync(process.execPath, [entrypoint, ...args], { + cwd: desktopRoot, + env: { ...process.env, ...options.env }, + stdio: "inherit", + }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +export function runTauriCommand(args) { + if (args[0] !== "build") return runTauri(args); + + // Tauri runs beforeBuildCommand and then consumes frontendDist. Give the + // entire invocation a private directory so concurrent OSS/internal packages + // cannot replace one another's assets between those two operations. + const invocationRoot = mkdtempSync( + path.join(tmpdir(), "buzz-tauri-package-assets-"), + ); + const frontendDist = path.join(invocationRoot, "dist"); + const outputOverride = JSON.stringify({ build: { frontendDist } }); + + try { + const delimiterIndex = args.indexOf("--"); + const configIndex = delimiterIndex === -1 ? args.length : delimiterIndex; + const tauriArgs = [...args]; + tauriArgs.splice(configIndex, 0, "--config", outputOverride); + return runTauri(tauriArgs, { + env: { BUZZ_PROTECTED_BUILD_OUTPUT: frontendDist }, + }); + } finally { + rmSync(invocationRoot, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + process.exitCode = runTauriCommand(process.argv.slice(2)); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 68b702431af..7a3669f440e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ "axum", "base64 0.22.1", "dirs", + "fs2", "getrandom 0.4.3", "hex", "nix 0.31.3", @@ -3045,6 +3046,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f41fa2d6e39..0bc32817881 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -154,6 +154,6 @@ 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"] } -# The relay's media validation, so the snapshot-sharing tests can prove the -# full export → sanitize → relay-accept → import contract end to end. +# The relay's media validation, so desktop-produced snapshots and voice notes +# can prove their full client-sanitize → relay-accept contract end to end. buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist index cddadcc6e30..7d29c433358 100644 --- a/desktop/src-tauri/Info.plist +++ b/desktop/src-tauri/Info.plist @@ -7,7 +7,7 @@ CFBundleName Buzz NSMicrophoneUsageDescription - Buzz needs microphone access for voice huddles. + Buzz needs microphone access for voice huddles and voice notes. NSCameraUsageDescription Buzz needs camera access to record animated avatars. NSLocalNetworkUsageDescription diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2cdd785c735..8b0e63f12bc 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -18,8 +18,29 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); 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:rerun-if-env-changed=BUZZ_BUILD_DEMO_SLUG"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + if let Ok(slug) = std::env::var("BUZZ_BUILD_DEMO_SLUG") { + let valid = !slug.is_empty() + && slug.len() <= 48 + && slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && slug + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && slug + .bytes() + .last() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !valid { + panic!("BUZZ_BUILD_DEMO_SLUG must be a lowercase ASCII slug"); + } + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DEMO_SLUG={slug}"); + } + // 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() { diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 9cbb4444ab3..f1136e88923 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -36,8 +36,8 @@ pub struct AppState { pub workspace_apply_generation: AtomicU64, /// Defers managed-agent restore until `apply_workspace` installs relay and identity. pub managed_agent_restore_pending: AtomicBool, - /// Disabled by agent-managed profiles so agent profile updates survive start/restore. - pub managed_agent_profile_reconcile_enabled: AtomicBool, + /// Experiment state applied to managed-agent starts and profile reconciliation. + pub managed_agent_experiments: crate::managed_agents::ManagedAgentExperimentState, /// Shared shutdown signal checked by launch-time agent restoration. pub shutdown_started: AtomicBool, /// Serializes every managed-runtime transition that changes the protected @@ -129,6 +129,15 @@ pub struct AppState { /// bounded and letting a later leave correctly flip the channel back to /// `is_member=false`. pub pending_owned_channels: Mutex>, + /// NIP-11 `self` pubkeys keyed by relay WS URL, each with its fetch + /// instant. A relay's signing identity is effectively static, yet every + /// send-time agent revalidation used to re-GET the document — one of the + /// dominant costs of agent-mention send latency. Entries expire after + /// `identity_archive::RELAY_SELF_CACHE_TTL` so a relay-side key rotation + /// still converges. Keyed by URL, so switching communities can never serve + /// another relay's identity; only verified `Some` values are stored (an + /// outage or a document without `self` must stay retryable). + pub relay_self_cache: Mutex>, pub archive_db: crate::archive::ArchiveDb, } @@ -207,7 +216,7 @@ pub fn build_app_state() -> AppState { workspace_apply_lock: Arc::new(AsyncMutex::new(())), workspace_apply_generation: AtomicU64::new(0), managed_agent_restore_pending: AtomicBool::new(false), - managed_agent_profile_reconcile_enabled: AtomicBool::new(true), + managed_agent_experiments: crate::managed_agents::ManagedAgentExperimentState::default(), shutdown_started: AtomicBool::new(false), managed_agent_runtime_transition: Mutex::new(()), identity_mutation: Mutex::new(()), @@ -231,86 +240,13 @@ pub fn build_app_state() -> AppState { #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), pending_owned_channels: Mutex::new(std::collections::HashSet::new()), + relay_self_cache: Mutex::new(HashMap::new()), archive_db: crate::archive::ArchiveDb::default(), } } -impl AppState { - /// Lock the huddle state mutex, converting a poisoned-lock error to a String. - /// - /// Convenience wrapper — replaces 15+ instances of - /// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the - /// huddle module. - pub fn huddle(&self) -> Result, String> { - self.huddle_state.lock().map_err(|e| e.to_string()) - } - - pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { - self.session_config_cache.lock().ok()?.get(key).cloned() - } - - pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.insert(key, cache); - } - } - - pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.remove(key); - } - } - - pub fn clear_agent_session_caches(&self, pubkey: &str) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.retain(|key, _| key.pubkey != pubkey); - } - } - - /// Return the active identity keys if they are in a signable state. - /// - /// Returns `Err` when the identity is in a lost state (`identity_lost` - /// — ephemeral key, user must re-import their nsec) or when the keyring - /// is locked (`keyring_locked` — key is held in a keyring that is - /// unavailable this boot). All signing and publish commands must call - /// this instead of locking `state.keys` directly, so that recovery mode - /// blocks publishing under an invalid or inaccessible identity. - pub fn signing_keys(&self) -> Result { - if self - .identity_lost - .load(std::sync::atomic::Ordering::Acquire) - || self - .keyring_locked - .load(std::sync::atomic::Ordering::Acquire) - { - return Err("identity is in recovery mode; event signing is disabled \ - until the identity is restored and Buzz is relaunched" - .to_string()); - } - self.keys - .lock() - .map_err(|e| e.to_string()) - .map(|k| k.clone()) - } - - /// Emit the current huddle state to the frontend via Tauri event. - /// - /// Acquires both locks (app_handle + huddle_state), clones a snapshot, - /// releases both, then emits. Best-effort — no-op if either lock is - /// poisoned or the app_handle hasn't been set yet. - pub fn emit_huddle_state_changed(&self) { - let app = match self.app_handle.lock() { - Ok(guard) => guard.clone(), - Err(_) => return, - }; - let Some(app) = app else { return }; - let snapshot = match self.huddle_state.lock() { - Ok(hs) => hs.clone(), - Err(_) => return, - }; - crate::huddle::state::emit_huddle_state(&app, &snapshot); - } -} +#[path = "app_state_accessors.rs"] +mod accessors; /// Resolve the user's identity key from the app data directory and wire /// the resulting [`RecoveryState`] into `AppState`. @@ -634,23 +570,20 @@ fn resolve_identity_with_store( }) } -/// Recover from a corrupt nsec in the keyring (parse failed). Clear the bad -/// keyring value, then migrate a valid leftover `identity.key` if one exists. -/// If the migration marker is present but no valid file exists, the prior -/// identity is unrecoverable — return `Lost` recovery rather than silently -/// generating a new identity. Generating fresh is only correct when no prior -/// identity ever existed (no marker). The keyring delete is best-effort: a -/// delete failure logs and continues — it must never block startup. +/// Recover from an unparseable keyring nsec, preferring a valid `identity.key`. +/// If a migration marker exists without a valid file, retain the keyring value +/// and return `Lost`. Without a marker, preserve the existing generate-fresh policy. fn recover_from_keyring( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, error: &str, ) -> Result { - eprintln!("buzz-desktop: corrupt nsec in keyring ({error}), clearing and recovering from file"); - if let Err(e) = store.delete(IDENTITY_KEY_NAME) { - eprintln!("buzz-desktop: failed to clear corrupt keyring value: {e}"); - } + eprintln!( + "buzz-desktop: corrupt nsec in keyring ({error}), looking for a recovery path before clearing" + ); + // Marker-only installs have no file fallback. Keep unreadable keyring + // material until a replacement exists rather than destroying the only copy. if legacy_path.exists() { if let Some(keys) = migrate_identity_file(store, legacy_path, data_dir)? { return Ok(ResolvedIdentity { @@ -661,13 +594,13 @@ fn recover_from_keyring( } } // No valid file to recover from. If the migration marker exists, a prior - // identity was stored in the keyring and is now corrupt AND gone — the key - // is unrecoverable. Enter Lost recovery instead of silently rotating. + // identity was stored in the keyring — keep the corrupt entry for support / + // manual export and enter Lost rather than silently rotating. if migration_marker_path(data_dir).exists() { let ephemeral = Keys::generate(); eprintln!( - "buzz-desktop: identity lost — keyring had corrupt data and no valid identity.key \ - backup; prior identity (migration marker present) is unrecoverable; \ + "buzz-desktop: identity lost — keyring value failed to parse and no valid identity.key \ + backup exists; leaving the keyring entry in place; \ using ephemeral key {}, awaiting user re-import", ephemeral.public_key().to_hex() ); @@ -677,7 +610,10 @@ fn recover_from_keyring( storage: IdentityStorage::Ephemeral, }); } - // No marker: genuine first launch with a corrupt keyring. Generate fresh. + // No marker: preserve the existing clear-and-generate first-launch policy. + if let Err(e) = store.delete(IDENTITY_KEY_NAME) { + eprintln!("buzz-desktop: failed to clear corrupt keyring value: {e}"); + } let (keys, storage) = generate_and_persist(store, legacy_path, data_dir)?; Ok(ResolvedIdentity { keys, diff --git a/desktop/src-tauri/src/app_state_accessors.rs b/desktop/src-tauri/src/app_state_accessors.rs new file mode 100644 index 00000000000..72744e1605e --- /dev/null +++ b/desktop/src-tauri/src/app_state_accessors.rs @@ -0,0 +1,87 @@ +//! Convenience accessors over [`AppState`]'s lock-guarded fields. +//! +//! Kept apart from `app_state.rs`, which owns the struct, its builder, and the +//! identity-key resolution that populates it. + +use nostr::Keys; + +use crate::app_state::AppState; +use crate::managed_agents::config_bridge::SessionConfigCache; +use crate::managed_agents::ManagedAgentRuntimeKey; + +impl AppState { + /// Lock the huddle state mutex, converting a poisoned-lock error to a String. + /// + /// Convenience wrapper — replaces 15+ instances of + /// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the + /// huddle module. + pub fn huddle(&self) -> Result, String> { + self.huddle_state.lock().map_err(|e| e.to_string()) + } + + pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { + self.session_config_cache.lock().ok()?.get(key).cloned() + } + + pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.insert(key, cache); + } + } + + pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.remove(key); + } + } + + pub fn clear_agent_session_caches(&self, pubkey: &str) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.retain(|key, _| key.pubkey != pubkey); + } + } + + /// Return the active identity keys if they are in a signable state. + /// + /// Returns `Err` when the identity is in a lost state (`identity_lost` + /// — ephemeral key, user must re-import their nsec) or when the keyring + /// is locked (`keyring_locked` — key is held in a keyring that is + /// unavailable this boot). All signing and publish commands must call + /// this instead of locking `state.keys` directly, so that recovery mode + /// blocks publishing under an invalid or inaccessible identity. + pub fn signing_keys(&self) -> Result { + if self + .identity_lost + .load(std::sync::atomic::Ordering::Acquire) + || self + .keyring_locked + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("identity is in recovery mode; event signing is disabled \ + until the identity is restored and Buzz is relaunched" + .to_string()); + } + self.keys + .lock() + .map_err(|e| e.to_string()) + .map(|k| k.clone()) + } + + /// Emit the current huddle state to the frontend via Tauri event. + /// + /// Acquires both locks (app_handle + huddle_state), clones a snapshot, + /// releases both, then emits. Best-effort — no-op if either lock is + /// poisoned or the app_handle hasn't been set yet. + pub fn emit_huddle_state_changed(&self) { + let app = match self.app_handle.lock() { + Ok(guard) => guard.clone(), + Err(_) => return, + }; + let Some(app) = app else { return }; + let snapshot = match self.huddle_state.lock() { + Ok(hs) => hs.clone(), + Err(_) => return, + }; + crate::huddle::state::emit_huddle_state(&app, &snapshot); + } +} diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f58..7684355a5bc 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -7,7 +7,12 @@ fn dev_keyring_service(configured: Option) -> String { } pub(crate) fn keyring_service() -> &'static str { - if cfg!(debug_assertions) { + if crate::build_identity::is_demo_build() { + static DEMO_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); + DEMO_SERVICE + .get_or_init(|| crate::build_identity::keyring_service().into_owned()) + .as_str() + } else if cfg!(debug_assertions) { static DEV_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); DEV_SERVICE .get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok())) diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 751bcf22e59..ceef4d3f93e 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -326,8 +326,8 @@ fn corrupt_keyring_recovers_valid_file_without_rotating() { // nsec (Present) AND a valid `identity.key` is on disk (leftover from a // failed prior migration), recovery must RECOVER THE FILE'S identity — // not quarantine the file and rotate to a fresh key (the original - // hazard). The corrupt keyring value must be cleared and replaced by the - // file's key (migrated in). + // hazard). Recovery must overwrite the corrupt keyring value with the + // file's key without deleting the keyring entry first. let dir = tempfile::tempdir().unwrap(); let legacy_path = dir.path().join("identity.key"); let file_keys = Keys::generate(); @@ -338,8 +338,8 @@ fn corrupt_keyring_recovers_valid_file_without_rotating() { // The FILE's identity is recovered — NOT a freshly generated one. assert_key_eq(&file_keys, &resolved.keys); - // The corrupt keyring value was cleared. - assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]); + // Recovery overwrites the corrupt value without deleting first. + assert!(store.deleted.borrow().is_empty()); // The keyring now holds the file's key (migrated in, read-back verified). let file_nsec = file_keys.secret_key().to_bech32().unwrap(); assert_eq!( @@ -1363,13 +1363,9 @@ fn verify_fails_store_does_not_write_marker_or_delete_file() { ); } -// ── I2: corrupt keyring + marker = Lost recovery ────────────────────────── - #[test] fn corrupt_keyring_marker_present_no_file_is_lost() { - // I2: Present(corrupt) + migration marker + no identity.key → the prior - // identity was migrated into the keyring and is now unrecoverable (corrupt - // AND no file backup). Must enter Lost recovery, NOT generate a fresh key. + // I2: corrupt keyring + marker + no file → Lost (do not mint a fresh key). let dir = tempfile::tempdir().unwrap(); let legacy_path = dir.path().join("identity.key"); write_migration_marker(&migration_marker_path(dir.path())).unwrap(); @@ -1378,22 +1374,33 @@ fn corrupt_keyring_marker_present_no_file_is_lost() { let store = FakeIdentityStore::present_with("not-a-valid-nsec"); let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - // Must enter Lost recovery — a prior identity existed and is now unrecoverable. - assert_eq!( - resolved.recovery, - RecoveryState::Lost, - "corrupt keyring + marker + no file must return Lost recovery, not a fresh key" - ); - - // No identity.key written — the ephemeral key is in-memory only. + assert_eq!(resolved.recovery, RecoveryState::Lost); + // Lost must keep the corrupt keyring entry for support/export. + assert!(!store + .deleted + .borrow() + .contains(&IDENTITY_KEY_NAME.to_string())); + assert!(store.slot.borrow().contains_key(IDENTITY_KEY_NAME)); assert!(!legacy_path.exists()); } +#[test] +fn corrupt_keyring_with_valid_file_recovers_before_delete() { + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + let store = FakeIdentityStore::present_with("not-a-valid-nsec"); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + assert_eq!(resolved.recovery, RecoveryState::None); + assert_key_eq(&file_keys, &resolved.keys); + assert!(store.deleted.borrow().is_empty()); +} + #[test] fn corrupt_keyring_no_marker_no_file_generates_fresh() { - // I2 (counter-case): Present(corrupt) + NO marker + no identity.key → - // genuine first launch with a corrupt keyring, no prior identity to - // protect. generate_and_persist is still the correct last resort. + // I2 counter-case: corrupt keyring, no marker, no file → generate fresh. let dir = tempfile::tempdir().unwrap(); let legacy_path = dir.path().join("identity.key"); assert!(!legacy_path.exists()); @@ -1402,16 +1409,9 @@ fn corrupt_keyring_no_marker_no_file_generates_fresh() { let store = FakeIdentityStore::present_with("not-a-valid-nsec"); let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - // No lost recovery — this is a fresh machine with no prior identity. - assert_eq!( - resolved.recovery, - RecoveryState::None, - "corrupt keyring + no marker + no file must generate a fresh key (no prior identity)" - ); - - // A fresh, valid key was stored (keyring or file). + assert_eq!(resolved.recovery, RecoveryState::None); assert!( store.slot.borrow().contains_key(IDENTITY_KEY_NAME) || legacy_path.exists(), - "a fresh key must be stored in the keyring or the file after generate_and_persist" + "fresh key must be stored after generate_and_persist" ); } diff --git a/desktop/src-tauri/src/archive/metric_store.rs b/desktop/src-tauri/src/archive/metric_store.rs index 9595e4d3323..78363223063 100644 --- a/desktop/src-tauri/src/archive/metric_store.rs +++ b/desktop/src-tauri/src/archive/metric_store.rs @@ -9,7 +9,7 @@ //! via [`AgentMetricIndexRow::from_payload`]. //! //! Kept in a sibling file (not `store.rs`) to keep that file under the -//! 1000-line gate, per the existing `pipeline.rs` precedent. +//! 1500-line gate, per the existing `pipeline.rs` precedent. use rusqlite::{params, Connection, OptionalExtension}; diff --git a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs index 2dc568d701c..337dbac922b 100644 --- a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs +++ b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs @@ -1,7 +1,7 @@ //! Kind-44200 (NIP-AM agent turn metric) archive and `get_agent_usage_series` //! integration tests for `archive/mod.rs`. //! -//! Kept in a sibling file so `mod_tests.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `mod_tests.rs` stays under the 1500-line gate; //! `#[path]`-included from there so the shared fixtures (`in_memory`, //! `add_sub`, `candidate`, `make_observer_frame`, `run_batch_sync_with_keys`) //! stay private to `mod_tests`. diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs index 21587669268..c589b5bd522 100644 --- a/desktop/src-tauri/src/archive/mod_tests.rs +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -1,6 +1,6 @@ //! Unit and integration tests for `archive/mod.rs`. //! -//! Kept in a sibling file so `mod.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `mod.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::pipeline::BucketWithResult; @@ -622,7 +622,7 @@ fn test_commit_archive_rolls_back_when_scope_write_would_fail() { } // Kind-44200 agent-turn-metric coverage lives in a sibling file to keep this -// one under the 1000-line gate; nested here (not in `mod.rs`) so it inherits +// one under the 1500-line gate; nested here (not in `mod.rs`) so it inherits // the shared fixtures above through `use super::*`. #[path = "mod_agent_metric_tests.rs"] mod agent_metric; diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index 98ff64dff48..f2fb3e6b895 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -1,6 +1,6 @@ //! Archive pipeline — three-phase plan/query/commit split. //! -//! Separated from `mod.rs` to keep that file under the 1000-line gate. +//! Separated from `mod.rs` to keep that file under the 1500-line gate. //! //! # Send-safety //! diff --git a/desktop/src-tauri/src/archive/retention.rs b/desktop/src-tauri/src/archive/retention.rs index 5ee9acff200..2e150da97a7 100644 --- a/desktop/src-tauri/src/archive/retention.rs +++ b/desktop/src-tauri/src/archive/retention.rs @@ -10,7 +10,7 @@ //! Phase-2 prune scan, the get/set accessors for the observer window, and the //! PRAGMA-based size readout. The prune worker itself lands in Phase 2. //! -//! Kept in a sibling file (not `store.rs`) to respect the 1000-line gate, per +//! Kept in a sibling file (not `store.rs`) to respect the 1500-line gate, per //! the existing `metric_store.rs` / `pipeline.rs` / `store_migrations.rs` //! precedent. diff --git a/desktop/src-tauri/src/archive/retention_tests.rs b/desktop/src-tauri/src/archive/retention_tests.rs index 26e6a25fdae..122cd01a99a 100644 --- a/desktop/src-tauri/src/archive/retention_tests.rs +++ b/desktop/src-tauri/src/archive/retention_tests.rs @@ -1,7 +1,7 @@ //! Behavior tests for the observer-retention setting, the size readout, and the //! M4 migration. //! -//! Kept in a sibling file so `retention.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `retention.rs` stays under the 1500-line gate; //! `#[path]`-included from there. `super::*` brings the retention API (and its //! `rusqlite::{params, Connection}` imports) into scope; `super::super::store` //! reaches the neighbouring subscription mutators and the base `SCHEMA`. diff --git a/desktop/src-tauri/src/archive/store_migration_tests.rs b/desktop/src-tauri/src/archive/store_migration_tests.rs index 6a40d7f4cd7..6aa585cfb46 100644 --- a/desktop/src-tauri/src/archive/store_migration_tests.rs +++ b/desktop/src-tauri/src/archive/store_migration_tests.rs @@ -1,6 +1,6 @@ //! Migration tests for `archive/store.rs` — M1: harness column. //! -//! Kept in a sibling file so `store_tests.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `store_tests.rs` stays under the 1500-line gate; //! `#[path]`-included from `store.rs`. use super::*; diff --git a/desktop/src-tauri/src/archive/store_migrations.rs b/desktop/src-tauri/src/archive/store_migrations.rs index 35a21e25d45..82a24e581c3 100644 --- a/desktop/src-tauri/src/archive/store_migrations.rs +++ b/desktop/src-tauri/src/archive/store_migrations.rs @@ -4,7 +4,7 @@ //! `archive_migrations`, so a migration that already ran is a no-op. //! //! Kept in a sibling file (not `store.rs`) to keep that file under the -//! 1000-line gate, per the existing `metric_store.rs` / `pipeline.rs` +//! 1500-line gate, per the existing `metric_store.rs` / `pipeline.rs` //! precedent. use rusqlite::{params, Connection}; diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index c0f85430d4d..b7e02d8f4dc 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `archive/store.rs`. //! -//! Kept in a sibling file so `store.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `store.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::*; diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs new file mode 100644 index 00000000000..ee84696c7f0 --- /dev/null +++ b/desktop/src-tauri/src/build_identity.rs @@ -0,0 +1,183 @@ +//! Compile-time identity for reusable named demo builds. +//! +//! Production builds leave `BUZZ_DESKTOP_BUILD_DEMO_SLUG` unset and retain all +//! existing names. The demo recipe validates one slug and `build.rs` bakes it +//! into the binary; every runtime identity is then derived from that one value. + +use std::borrow::Cow; + +pub(crate) fn demo_slug() -> Option<&'static str> { + option_env!("BUZZ_DESKTOP_BUILD_DEMO_SLUG") +} + +pub(crate) fn is_demo_build() -> bool { + demo_slug().is_some() +} + +pub(crate) const DEMO_AGENT_CONFIG_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + +pub(crate) fn demo_config_home() -> Result, String> { + demo_config_home_for(demo_slug(), dirs::config_dir()) +} + +pub(crate) fn demo_agent_oauth_cache_dir() -> Result, String> { + Ok(demo_config_home()?.map(|dir| dir.join("buzz-agent").join("oauth"))) +} + +/// Keep child config caches inside this demo build's identity. In particular, +/// bundled buzz-agent OAuth tokens must not read or write production's root. +/// Refuse launch if a demo cannot resolve its root; None means production only. +pub(crate) fn apply_demo_config_home(command: &mut std::process::Command) -> Result<(), String> { + if let Some(config_home) = demo_config_home()? { + command.env(DEMO_AGENT_CONFIG_ENV, config_home); + } + Ok(()) +} + +fn demo_config_home_for( + demo_slug: Option<&str>, + config_dir: Option, +) -> Result, String> { + match demo_slug { + None => Ok(None), + Some(slug) => config_dir + .map(|dir| Some(dir.join(format!("buzz-demo-{slug}")))) + .ok_or_else(|| "cannot resolve demo credential directory".to_string()), + } +} + +pub(crate) fn deep_link_scheme() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-demo-{slug}"))) + .unwrap_or(Cow::Borrowed("buzz")) +} + +pub(crate) fn is_deep_link_for_build(value: &str) -> bool { + is_deep_link_for_scheme(value, deep_link_scheme().as_ref()) +} + +fn is_deep_link_for_scheme(value: &str, scheme: &str) -> bool { + value + .strip_prefix(scheme) + .is_some_and(|suffix| suffix.starts_with("://")) +} + +pub(crate) fn keyring_service() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-desktop-demo.{slug}"))) + .unwrap_or(Cow::Borrowed("buzz-desktop")) +} + +pub(crate) fn nest_name(is_dev: bool) -> Cow<'static, str> { + nest_name_for(demo_slug(), is_dev) +} + +fn nest_name_for(demo_slug: Option<&str>, is_dev: bool) -> Cow<'_, str> { + if let Some(slug) = demo_slug { + Cow::Owned(format!(".buzz-demo-{slug}")) + } else if is_dev { + Cow::Borrowed(".buzz-dev") + } else { + Cow::Borrowed(".buzz") + } +} + +pub(crate) fn cli_name(is_dev: bool) -> String { + if let Some(slug) = demo_slug() { + format!("buzz-demo-{slug}") + } else if is_dev { + "buzz-dev".to_string() + } else { + "buzz".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "compiled with BUZZ_BUILD_DEMO_SLUG by the compiled-flags recipe"] + fn compiled_demo_slug_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_DEMO_SLUG") + .expect("BUZZ_TEST_EXPECTED_DEMO_SLUG must be set"); + assert_eq!(demo_slug(), Some(expected.as_str())); + } + + #[test] + fn ordinary_release_defaults_remain_production_identity() { + if demo_slug().is_none() { + assert_eq!(deep_link_scheme(), "buzz"); + assert_eq!(keyring_service(), "buzz-desktop"); + assert_eq!(nest_name(false), ".buzz"); + assert_eq!(cli_name(false), "buzz"); + } + } + + #[test] + fn demo_agent_config_and_oauth_roots_are_build_scoped() { + let base = std::path::PathBuf::from("/Users/demo/Library/Application Support"); + assert_eq!( + demo_config_home_for(None, Some(base.clone())).unwrap(), + None + ); + let first = demo_config_home_for(Some("board-1234567812345678"), Some(base.clone())) + .unwrap() + .unwrap(); + let second = demo_config_home_for(Some("board-8765432187654321"), Some(base)) + .unwrap() + .unwrap(); + assert_eq!( + first, + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678" + ) + ); + assert_eq!( + first.join("buzz-agent/oauth"), + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) + ); + assert_ne!(first, second); + } + + #[test] + fn unresolved_demo_credentials_never_select_production_defaults() { + assert_eq!(demo_config_home_for(None, None).unwrap(), None); + assert_eq!( + demo_config_home_for(Some("board-1234567812345678"), None), + Err("cannot resolve demo credential directory".to_string()) + ); + } + + #[test] + fn duplicate_instance_links_follow_the_build_scheme() { + assert!(is_deep_link_for_scheme("buzz://message?id=1", "buzz")); + assert!(!is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz" + )); + assert!(is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz-demo-board-1234567812345678" + )); + assert!(!is_deep_link_for_scheme( + "buzz://message?id=1", + "buzz-demo-board-1234567812345678" + )); + } + + #[test] + fn production_and_named_demo_nests_are_distinct() { + assert_eq!(nest_name_for(None, false), ".buzz"); + assert_eq!( + nest_name_for(Some("workstream-board"), false), + ".buzz-demo-workstream-board" + ); + assert_eq!( + nest_name_for(Some("second-demo"), false), + ".buzz-demo-second-demo" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 4df24e6e9ba..16b4c93b3e6 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -14,9 +14,8 @@ use crate::{ }, current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env, - save_managed_agents, sync_managed_agent_processes, AgentDefinition, BackendKind, - GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, - MAX_ENV_VALUE_BYTES, + save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, + KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, }; @@ -535,42 +534,15 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< (models, current_model) } -/// Persist the canonical startup effort level for a local managed agent. -/// -/// B5 (v4 direct-write): the panel's EffortPicker calls this directly to set the -/// effort a spawn will apply at next session start. The value is stored on the -/// record; at spawn `runtime.rs` injects it as `BUZZ_ACP_EFFORT_LEVEL` and the -/// harness applies it via `session/set_config_option` against the adapter's -/// advertised `thought_level` configId. Pass `None` to clear (adapter default). -/// -/// Rejects non-local backends: remote agents receive effort through `policy_env` -/// at deploy time (see `agents_deploy.rs`), never this local persistence path — -/// so an effort edit against a deployed agent is a caller error, not a silent -/// no-op that leaves the panel and the running agent disagreeing. -#[tauri::command] -pub fn persist_agent_effort_level( - pubkey: String, +/// Atomically set the record's canonical effort column and strip every stale +/// record-scope effort env alias. Split from the Tauri command so the invariant +/// — no leftover alias can outrank the just-set column — is directly testable. +pub(crate) fn apply_picker_effort_level( + record: &mut ManagedAgentRecord, effort_level: Option, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let record = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - if record.backend != BackendKind::Local { - return Err(format!( - "agent {pubkey} is not a local agent; remote effort is set at deploy time" - )); - } +) { record.effort_level = effort_level; - record.updated_at = crate::util::now_iso(); - save_managed_agents(&app, &records) + crate::managed_agents::remove_record_effort_aliases(&mut record.env_vars); } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 13bcb5d4efa..093e925f18a 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -1,5 +1,5 @@ //! Unit tests for `commands/agent_config.rs` (split to keep `agent_config.rs` -//! under the 1000-line file-size ratchet). +//! under the 1500-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. @@ -29,7 +29,7 @@ fn with_no_goose_config(body: impl FnOnce() -> T) -> T { } fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -55,17 +55,21 @@ fn goose_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, 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, - } + }; + &RUNTIME } fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: Some("persona-1".to_string()), @@ -127,6 +131,7 @@ fn agent_record() -> ManagedAgentRecord { fn persona_with_model(model: &str) -> AgentDefinition { AgentDefinition { + description: None, id: "persona-1".to_string(), display_name: "Persona".to_string(), avatar_url: None, @@ -628,6 +633,58 @@ fn baked_env_mixed_keys_correct_masking() { assert!(token.masked); } +/// F1 picker direct-write invariant: a stale record-native `GOOSE_THINKING_EFFORT` +/// (launch-projection tier 1, ABOVE the canonical column) must not survive a +/// picker write. Setting effort `high` through the picker path both writes the +/// column and sweeps the stale alias, so the reader and the launch projection +/// both resolve `high` — not the stale `low`. Deleting the sweep in +/// `apply_picker_effort_level` re-breaks this: the projection would emit `low`. +#[test] +fn picker_write_sweeps_stale_record_native_effort_alias() { + let mut record = agent_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + super::apply_picker_effort_level(&mut record, Some("high".to_string())); + + // The stale record-native alias is gone; only the column carries the value. + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "stale record-native effort alias must be swept by the picker write" + ); + assert_eq!(record.effort_level.as_deref(), Some("high")); + + // Reader: the panel resolves the just-set value, not the stale alias. + let surface = with_no_goose_config(|| { + resolve_config_surface( + record.clone(), + &[], + Some(goose_runtime()), + None, + &Default::default(), + None, + ) + }); + let effort = surface + .normalized + .thinking_effort + .expect("picker-set effort must resolve"); + assert_eq!(effort.value.as_deref(), Some("high")); + + // Launch projection: the spawned child receives the picker value. + let launch = crate::managed_agents::config_bridge::effort::effort_launch_projection( + &record, + Some(goose_runtime()), + &[], + None, + &std::collections::BTreeMap::new(), + None, + &std::collections::BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + #[test] fn baked_env_thinking_effort_is_unmasked() { // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 95e9759f10e..ccca7c4abfa 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -17,7 +17,6 @@ fn active_installs() -> &'static std::sync::Mutex = member_agent_channel_ids.keys().cloned().collect(); - if candidate_pubkeys.is_empty() { - return Ok(Vec::new()); - } - - let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); - let profile_filters = exact_author_filters(&candidate_pubkeys, 0); - // One semaphore per rebuild caps `/query` requests across this rebuild's - // phases, so its runtime-directory and owner-profile phases below stay - // within the ceiling even though `try_join!` runs them concurrently. + let membership_query = async { + query_all_relay_pages(state, membership_filter) + .await + .map_err(|error| format!("relay agent channel-membership query failed: {error}")) + }; + // One semaphore per rebuild caps batched `/query` requests across this + // rebuild's phases, so its runtime-directory and owner-profile phases stay + // within the ceiling even though `try_join!` runs them concurrently. The + // owned-agent and membership pagers are single sequential request streams + // and run outside the semaphore, so the targeted path's ceiling is the + // batches plus two. let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); - let (directory_events, profile_events) = tokio::try_join!( - query_filter_batches( - state, - &semaphore, - &directory_filters, - "relay agent runtime-directory query failed", - ), - query_filter_batches( - state, - &semaphore, - &profile_filters, - "relay agent owner-profile query failed", - ), - )?; + let (member_agent_channel_ids, candidate_pubkeys, directory_events, profile_events) = + if let Some(requested_pubkeys) = requested_pubkeys { + // Targeted path: the caller already names the candidates, so + // neither the owned-agent read nor the membership read gates the + // directory/profile fan-out — they all join it, one round-trip + // stage instead of three. The owned read is `#d`-scoped to the + // requested keys, so it can only ever name candidates already in + // this set. Directory, profile, and (below) policy reads may now + // issue for requested pubkeys membership excludes — bounded by the + // user-typed mention set — but the membership/owner retain on the + // final result still drops them, so what is returned is identical. + let candidate_pubkeys: Vec = requested_pubkeys.iter().cloned().collect(); + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + let (owned_events, membership_events, directory_events, profile_events) = tokio::try_join!( + owned_query, + membership_query, + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; + let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( + &membership_events, + &relay_pubkey, + &owned_candidates, + ); + member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); + ( + member_agent_channel_ids, + candidate_pubkeys, + directory_events, + profile_events, + ) + } else { + // Full rebuild: the owned-agent and membership reads *discover* the + // candidates, so both must resolve before the batch filters can be + // built. Sequential shape retained — this is the autocomplete path, + // not the send path. + let owned_events = owned_query.await?; + let membership_events = membership_query.await?; + let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( + &membership_events, + &relay_pubkey, + &owned_candidates, + ); + let candidate_pubkeys: Vec = member_agent_channel_ids + .keys() + .cloned() + .chain(owned_candidates) + .collect::>() + .into_iter() + .collect(); + if candidate_pubkeys.is_empty() { + return Ok(Vec::new()); + } + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + let (directory_events, profile_events) = tokio::try_join!( + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; + ( + member_agent_channel_ids, + candidate_pubkeys, + directory_events, + profile_events, + ) + }; // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current @@ -206,7 +292,10 @@ async fn list_relay_agents_for_selection( &mut agents, crate::managed_agents::owner_only_access_build(), ); - agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); + agents.retain(|agent| { + member_agent_channel_ids.contains_key(&agent.pubkey) + || agent.owner_pubkey.as_deref() == Some(viewer_pubkey.as_str()) + }); for agent in &mut agents { agent.channel_ids = member_agent_channel_ids .get(&agent.pubkey) @@ -506,6 +595,7 @@ mod real_relay_tests { &agent, "Agent Probe", None, + None, Some(&auth_tag), ) .await @@ -568,3 +658,6 @@ mod real_relay_tests { assert_eq!(emitted_mentions, vec![agent.public_key().to_hex()]); } } + +#[cfg(test)] +mod owned_tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs new file mode 100644 index 00000000000..bb42b3e6d24 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs @@ -0,0 +1,192 @@ +//! Exercise the production query plan against a loopback relay with signed fixtures. +use super::*; +use axum::{ + routing::{get, post}, + Json, Router, +}; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use std::sync::{Arc, Mutex}; + +#[tokio::test] +async fn remote_owned_discovery_and_membership_do_not_require_local_records() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let relay = Keys::generate(); + let owner = Keys::generate(); + let agent = Keys::generate(); + let stranger = Keys::generate(); + let agent_key = agent.public_key().to_hex(); + let owner_key = owner.public_key().to_hex(); + let relay_key = relay.public_key().to_hex(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let auth: Vec = serde_json::from_str(&auth).unwrap(); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Remote Scout"}"#) + .tags([Tag::parse(auth).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + let policy = |key: &str| { + EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Remote Scout","parallelism":1,"respond_to":"owner-only"}"#, + ) + .tags([Tag::parse(["d", key]).unwrap()]) + .sign_with_keys(&owner) + .unwrap() + }; + // An owner-authored coordinate is a discovery hint, not ownership proof. + let forged = policy(&stranger.public_key().to_hex()); + let stranger_profile = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&stranger) + .unwrap(); + let events = Arc::new(Mutex::new(vec![ + profile, + policy(&agent_key), + forged, + stranger_profile, + ])); + let queries = Arc::new(Mutex::new(Vec::::new())); + let query_events = events.clone(); + let query_log = queries.clone(); + let router = Router::new() + .route( + "/", + get(move || { + let key = relay_key.clone(); + async move { Json(serde_json::json!({"self": key})) } + }), + ) + .route( + "/query", + post(move |Json(filters): Json>| { + let events = query_events.clone(); + let queries = query_log.clone(); + async move { + queries.lock().unwrap().extend(filters.clone()); + let events = events.lock().unwrap(); + let result: Vec<_> = events + .iter() + .filter(|event| { + filters.iter().any(|filter| { + filter["kinds"] + .as_array() + .unwrap() + .contains(&serde_json::json!(event.kind.as_u16())) + && filter.get("authors").is_none_or(|authors| { + authors + .as_array() + .unwrap() + .contains(&serde_json::json!(event.pubkey.to_hex())) + }) + && ["d", "p"].iter().all(|tag| { + filter.get(format!("#{tag}")).is_none_or(|values| { + event.tags.iter().any(|t| { + t.as_slice().first().map(String::as_str) + == Some(*tag) + && t.as_slice().get(1).is_some_and(|value| { + values + .as_array() + .unwrap() + .contains(&serde_json::json!(value)) + }) + }) + }) + }) + }) + }) + .cloned() + .collect(); + Json(result) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = owner.clone(); + *state.relay_url_override.lock().unwrap() = Some(format!("ws://{address}")); + + let discovered = list_relay_agents_for_state(&state).await.unwrap(); + assert_eq!(discovered.len(), 1, "forged ownership must not be admitted"); + assert_eq!(discovered[0].pubkey, agent_key); + assert_eq!( + discovered[0].owner_pubkey.as_deref(), + Some(owner_key.as_str()) + ); + assert!( + discovered[0].channel_ids.is_empty(), + "discovery is not membership" + ); + + let membership = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &owner_key, "", "member"]).unwrap(), + Tag::parse(["p", &agent_key, "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + events.lock().unwrap().push(membership); + let requested = std::collections::HashSet::from([agent_key.clone()]); + let admitted = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert_eq!(admitted.len(), 1); + assert_eq!(admitted[0].channel_ids, vec!["general".to_string()]); + let outside = list_relay_agents_for_selection(&state, Some(&requested), Some("private-other")) + .await + .unwrap(); + assert_eq!(outside.len(), 1); + assert!( + outside[0].channel_ids.is_empty(), + "ownership cannot fabricate destination membership" + ); + // A newer signed snapshot revokes membership, even if an old snapshot + // is also returned. The owned identity remains discoverable, not admitted. + let removed = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &owner_key, "", "member"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() + 1, + )) + .sign_with_keys(&relay) + .unwrap(); + events.lock().unwrap().push(removed); + let revoked = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert!(revoked[0].channel_ids.is_empty()); + + let deny = EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Remote Scout","parallelism":1,"respond_to":"nobody"}"#, + ) + .tags([Tag::parse(["d", &agent_key]).unwrap()]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() + 2, + )) + .sign_with_keys(&owner) + .unwrap(); + events.lock().unwrap().push(deny); + let denied = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert!( + denied.is_empty(), + "latest unsupported policy cannot fall back to an older allow" + ); + + assert!(queries + .lock() + .unwrap() + .iter() + .any(|filter| filter["kinds"] == serde_json::json!([30177]) + && filter["authors"] == serde_json::json!([owner_key]) + && filter.get("#d").is_none())); + server.abort(); + crate::relay_admission::reset_rate_limit_gate(); +} diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27d..f671983bbc6 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -54,6 +54,8 @@ pub(super) async fn run_agent_models_command( for (k, v) in &merged_env { cmd.env(k, v); } + // Demo identity is authoritative and must win over ambient/user env. + crate::build_identity::apply_demo_config_home(&mut cmd)?; crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 05b1abad90d..c887b251485 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -26,7 +26,6 @@ use crate::{ UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, - util::now_iso, }; /// Query available models from an agent via `buzz-acp models --json`. diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 1f66f24c6a3..07f19f9a204 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -178,12 +178,23 @@ pub(super) async fn discover_databricks_models( parsed_filter.clone(), ); let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); + let oauth_cache_dir = crate::build_identity::demo_agent_oauth_cache_dir()?; - let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { + let entries = match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) if should_start_interactive_auth(&api_key) => { let _auth = AUTH_GATE.lock().await; - match buzz_agent_pkg::discover_databricks_models(&config).await { + match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { // A peer sign-in under the gate already succeeded. Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { @@ -194,22 +205,28 @@ pub(super) async fn discover_databricks_models( return Err(databricks_sign_in_required_error()); } run_interactive_databricks_auth( - buzz_agent_pkg::authenticate_databricks(&host), + buzz_agent_pkg::authenticate_databricks_with_cache_dir( + &host, + oauth_cache_dir.as_deref(), + ), AUTH_FLOW_TIMEOUT, &AUTH_COOLDOWNS, &host, &redaction_env, ) .await?; - buzz_agent_pkg::discover_databricks_models(&config) - .await - .map_err(|error| { - format_redacted_error( - "Databricks model discovery failed after sign-in", - &error, - &redaction_env, - ) - })? + buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + .map_err(|error| { + format_redacted_error( + "Databricks model discovery failed after sign-in", + &error, + &redaction_env, + ) + })? } Err(error) => { return Err(format_redacted_error( diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index d79e40bd20b..7c382a663b2 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -428,29 +428,20 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { ) .expect("sample managed agent record"); - let persona = crate::managed_agents::AgentDefinition { - id: "persona-1".to_string(), - display_name: "Persona".to_string(), - avatar_url: None, - system_prompt: "You are a persona.".to_string(), - runtime: Some("goose".to_string()), - model: Some("persona-model".to_string()), - provider: Some("anthropic".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, - team_catalog_source: None, - env_vars: BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "".to_string(), - updated_at: "".to_string(), - }; + let persona: crate::managed_agents::AgentDefinition = serde_json::from_str( + r#"{ + "id": "persona-1", + "display_name": "Persona", + "system_prompt": "You are a persona.", + "runtime": "goose", + "model": "persona-model", + "provider": "anthropic", + "is_active": true, + "created_at": "", + "updated_at": "" + }"#, + ) + .expect("sample persona"); // agent_model_discovery_config is the single helper get_agent_models // consumes — the stale record bytes must lose to the persona's current diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index bb045b81a24..2ef014d7956 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -35,6 +35,88 @@ fn ensure_access_policy_change_supported( Ok(()) } +/// Reject an effort mutation for a non-local record. Remote effort is +/// deployment-owned (set via `policy_env` at deploy time); persisting locally +/// would make the canonical column diverge from the deployed runtime's actual +/// effort. +fn ensure_effort_change_supported( + record: &ManagedAgentRecord, + effort_level: &Option>, +) -> Result<(), String> { + if effort_level.is_some() && record.backend != crate::managed_agents::BackendKind::Local { + return Err(format!( + "agent {} is not a local agent; remote effort is set at deploy time", + record.pubkey + )); + } + Ok(()) +} + +/// Guard/apply seam for the effort step inside `apply_record_field_updates`. +fn apply_effort_update( + record: &mut ManagedAgentRecord, + effort_level: Option>, +) -> Result<(), String> { + ensure_effort_change_supported(record, &effort_level)?; + if let Some(effort_override) = effort_level { + crate::commands::agent_config::apply_picker_effort_level(record, effort_override); + } + Ok(()) +} + +/// Proof token returned by `apply_record_field_updates`. Zero-size and +/// `#[must_use]`; consumed by `stamp_record_updated_at`, so removing the +/// `apply_record_field_updates` call from `update_managed_agent` leaves +/// `applied` undefined at the timestamp site — a compile error. +#[derive(Debug)] +#[must_use] +pub(crate) struct RecordFieldsApplied(()); + +/// Apply the env-vars and effort steps of `update_managed_agent` to a record +/// in the correct order: env_vars FIRST (so the same-request map cannot +/// reintroduce a stale alias), then the canonical effort column write. +/// +/// Returns a `RecordFieldsApplied` token that must be passed to +/// `stamp_record_updated_at`. Removing this call from `update_managed_agent` +/// leaves `applied` undefined at the timestamp site — a compile error. +/// +/// Called by `update_managed_agent` inside its locked transaction and by tests. +/// Any step deleted from inside this function is directly caught by the +/// corresponding test assertion. +/// +/// Mutation proofs (see `agent_models_update_tests.rs`): +/// - Deleting the `apply_effort_update` call leaves `effort_level` unchanged. +/// - Deleting `ensure_effort_change_supported` inside `apply_effort_update` +/// lets non-local writes pass `Ok(())` without mutating the column. +/// - Deleting `apply_picker_effort_level` inside `apply_effort_update` +/// leaves `effort_level == None` on a local-set request. +pub(crate) fn apply_record_field_updates( + record: &mut ManagedAgentRecord, + env_vars: Option<&std::collections::BTreeMap>, + inherit_transition: bool, + effort_level: Option>, +) -> Result { + // Order is load-bearing: env_vars before effort so a same-request + // env_vars map cannot reintroduce a stale alias after the column write. + crate::managed_agents::apply_env_vars_then_effort_transition( + record, + env_vars.cloned(), + inherit_transition, + ); + apply_effort_update(record, effort_level)?; + Ok(RecordFieldsApplied(())) +} + +/// Stamp `record.updated_at` with the current ISO timestamp, consuming the +/// `RecordFieldsApplied` proof token. Removing `apply_record_field_updates` +/// from `update_managed_agent` leaves `applied` undefined here — a compile error. +pub(crate) fn stamp_record_updated_at( + record: &mut ManagedAgentRecord, + _applied: RecordFieldsApplied, +) { + record.updated_at = crate::util::now_iso(); +} + /// Flush a retained managed-agent policy, preserving any earlier profile error. pub(crate) async fn flush_managed_agent_policy( app: &AppHandle, @@ -115,15 +197,17 @@ pub async fn update_managed_agent( // Harness edit: the persona's runtime is authoritative, so an explicit // `agent_command_override` is persisted ONLY when the user picks a // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit + // "Inherit from persona" sentinel clears the pin, the materialized + // record runtime, AND the per-instance effort override (column here, + // env aliases after `env_vars` is applied below). A name-only edit // (`agent_command == None`) leaves the pin intact. `harness_override` // threads the user's explicit intent — see `apply_agent_command_update` // and `update_time_agent_command_override` for the full resolution // rules. + let mut inherit_transition = false; if let Some(agent_command) = input.agent_command { let personas = load_personas(&app).unwrap_or_default(); - crate::managed_agents::apply_agent_command_update( + inherit_transition = crate::managed_agents::apply_agent_command_update( record, &personas, &agent_command, @@ -136,9 +220,16 @@ pub async fn update_managed_agent( // mcp_command is intentionally not applied here — the effective MCP // command is always catalog-derived (known_acp_runtime at spawn time) // and the per-record field is never read by the runtime. - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - record.env_vars = env_vars; + // + // Apply the caller-supplied `env_vars` (validated first), then — only on + // the pin→inherit transition — strip the record effort env aliases. The + // order is load-bearing: stripping AFTER the env replacement is what + // stops a same-request `env_vars` map from reintroducing a stale effort + // alias while the instance inherits its harness. The column was already + // cleared inside `apply_agent_command_update`. See + // `apply_env_vars_then_effort_transition` for the pinned invariant. + if let Some(ref env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(env_vars)?; } // Native provider/model fields are authoritative. Keep the typed marker @@ -211,7 +302,23 @@ pub async fn update_managed_agent( record.respond_to_allowlist = prospective_allowlist; } - record.updated_at = now_iso(); + // Effort + env_vars: applied together inside `apply_record_field_updates` to + // enforce the ordering invariant (env_vars before effort column write) and + // provide a directly-testable production seam. Effort persists inside the + // locked transaction so an access-policy restart above snapshots and + // launches the new effort value. Present+Some(v)=set; Present+None=clear; + // Absent=don't touch (the dialog sends it only when effortTouched). + // The returned token is consumed by `stamp_record_updated_at`; removing + // this call from `update_managed_agent` leaves `applied` undefined there + // — a compile error (the sole outer-seam proof for this call site). + let applied = apply_record_field_updates( + record, + input.env_vars.as_ref(), + inherit_transition, + input.effort_level, + )?; + + stamp_record_updated_at(record, applied); save_managed_agents(&app, &records)?; @@ -244,8 +351,16 @@ pub async fn update_managed_agent( .avatar_url .clone() .or_else(|| managed_agent_avatar_url(&effective_command)); + let about = crate::managed_agents::record_effective_description(record, &personas); let auth_tag = record.auth_tag.clone(); - Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) + Some(( + agent_keys, + relay_url, + display_name, + avatar_url, + about, + auth_tag, + )) } else { None }; @@ -291,13 +406,14 @@ pub async fn update_managed_agent( // A rename is committed only when profile sync succeeds; otherwise restore // the complete pre-edit record so Desktop and the relay keep one // authoritative name. - if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { + if let Some((agent_keys, relay_url, display_name, avatar_url, about, auth_tag)) = sync_params { if let Err(sync_error) = sync_managed_agent_profile( &state, &relay_url, &agent_keys, &display_name, avatar_url.as_deref(), + about.as_deref(), auth_tag.as_deref(), ) .await @@ -356,5 +472,6 @@ pub async fn update_managed_agent( } #[cfg(test)] +#[allow(unused_must_use)] #[path = "agent_models_update_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_update_tests.rs b/desktop/src-tauri/src/commands/agent_models_update_tests.rs index b9fd0bd1839..28a50e7b15b 100644 --- a/desktop/src-tauri/src/commands/agent_models_update_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_update_tests.rs @@ -1,4 +1,9 @@ use super::*; +// The tests call `apply_record_field_updates(...)` and consume the return value +// via `.expect(...)`, discarding `RecordFieldsApplied`. The tests verify column +// writes (side effects), not the token itself. The lint is suppressed here so +// callers remain readable. Production code (update_managed_agent) must never +// suppress it — the token IS the outer-seam compile-time proof. fn provider_record(deployed: bool) -> ManagedAgentRecord { let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ @@ -29,3 +34,340 @@ fn undeployed_provider_accepts_access_edits() { ensure_access_policy_change_supported(&provider_record(false), true) .expect("no running provider deployment can retain stale access"); } + +fn local_record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "local", "name": "Local 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() + // BackendKind deserializes as Local when the field is absent (the json! above). +} + +// ── Production-entered seam tests (apply_record_field_updates) ────────────── +// +// These tests call `apply_record_field_updates`, the same function production +// calls inside `update_managed_agent` for the env_vars+effort ordered write. +// They verify: +// - non-local records are rejected AND the column is NOT mutated; +// - local set writes to the column and sweeps stale env aliases; +// - local clear zeroes the column and sweeps stale env aliases; +// - env_vars applied before effort so no same-request alias re-pins the column. +// +// Deletion proof for the effort guard: removing `ensure_effort_change_supported` +// inside `apply_record_field_updates` makes reject tests return `Ok(())` instead +// of `Err`, and the "record not mutated" assertions fail. +// +// Deletion proof for the apply call: removing the `apply_effort_update` call +// inside `apply_record_field_updates` leaves `effort_level == None` on local-set. +// +// Deletion proof for the env_vars step: removing `apply_env_vars_then_effort_transition` +// inside `apply_record_field_updates` leaves the env alias in `env_vars` on local-set. +// +// Ordering proof: `env_vars` with a stale alias is applied BEFORE effort so the +// alias is stripped; reversing the order leaves both the alias and the new column. +// +// Outer-seam proof (compile-error): removing `apply_record_field_updates` from +// `update_managed_agent` leaves `applied` undefined at `stamp_record_updated_at` +// — a compile error enforced by the `#[must_use] RecordFieldsApplied` token. +// `record_field_updates_persist_effort_to_disk` below proves the +// disk-persistence contract of `apply_record_field_updates` itself (calls it +// directly); it does not independently gate the production invocation. + +#[test] +fn non_local_set_is_rejected_and_record_not_mutated() { + let mut record = provider_record(false); + let err = apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect_err("non-local record must reject effort writes"); + assert!( + err.contains("remote effort is set at deploy time"), + "error must explain why non-local effort writes are rejected: {err}" + ); + // Column must not be touched — the rejection is before mutation. + assert_eq!( + record.effort_level, None, + "non-local record column must be unchanged after a rejected set" + ); +} + +#[test] +fn non_local_clear_is_rejected_and_record_not_mutated() { + // Clear (None inner value) is also rejected for non-local records — the + // outer Some signals presence; the inner None is the clear sentinel. + let mut record = provider_record(false); + let err = apply_record_field_updates(&mut record, None, false, Some(None)) + .expect_err("non-local record effort clear must also be rejected"); + assert!(err.contains("remote effort is set at deploy time")); + assert_eq!( + record.effort_level, None, + "non-local record column must be unchanged after a rejected clear" + ); +} + +#[test] +fn local_set_writes_column_and_sweeps_stale_alias() { + // `apply_record_field_updates` must write `effort_level` for a local record + // and strip any stale record-scope effort alias. Deleting the + // `apply_effort_update` call inside leaves `effort_level == None`. + let mut record = local_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + let _ = apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "local set must write the canonical column" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "local set must sweep the stale record-native alias" + ); +} + +#[test] +fn local_clear_zeroes_column_and_sweeps_alias() { + let mut record = local_record(); + record.effort_level = Some("high".to_string()); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + + apply_record_field_updates(&mut record, None, false, Some(None)) + .expect("local record must accept effort clear"); + + assert_eq!( + record.effort_level, None, + "local clear must zero the canonical column" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "local clear must sweep the stale record-native alias" + ); +} + +#[test] +fn absent_effort_is_noop_for_any_backend() { + // A missing effortLevel field (the common case) must never be rejected and + // must never touch the column — this is the don't-touch path. + let mut local = local_record(); + apply_record_field_updates(&mut local, None, false, None) + .expect("absent effort must pass for local"); + assert_eq!( + local.effort_level, None, + "absent effort must not touch local column" + ); + + let mut provider = provider_record(true); + apply_record_field_updates(&mut provider, None, false, None) + .expect("absent effort must pass for provider"); + assert_eq!( + provider.effort_level, None, + "absent effort must not touch provider column" + ); +} + +#[test] +fn env_vars_applied_before_effort_ordering_invariant() { + // Order is load-bearing: env_vars BEFORE effort column write. A same-request + // env_vars map containing a stale alias (GOOSE_THINKING_EFFORT=low) alongside + // an explicit effort set (high) must end with the alias swept — not re-pinned. + // If env_vars were applied AFTER effort, the alias would survive. + let mut record = local_record(); + let mut env_vars = std::collections::BTreeMap::new(); + env_vars.insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + apply_record_field_updates( + &mut record, + Some(&env_vars), + false, + Some(Some("high".to_string())), + ) + .expect("ordering test must succeed for local record"); + + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "effort column must be set to the explicit value" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "alias in the same-request env_vars must be swept before the column is read at launch" + ); +} + +// ── Defensive direct-IPC contract ───────────────────────────────────────────── +// +// Non-blocking defensive coverage (Wes/Carl review): a contradictory request +// combining the ACP inherit sentinel in `env_vars` and a non-null effort_level +// must be deterministic — the effort write wins over the sentinel, and the +// sentinel is swept by the alias-removal step so it cannot shadow the column +// at launch time. The shipped renderer suppresses this combination, but the +// backend must not leave an ambiguous state. + +#[test] +fn effort_write_sweeps_acp_sentinel_in_env_vars() { + // A local record whose env_vars contain BUZZ_ACP_EFFORT_LEVEL (e.g. manually + // set by a user) plus a concurrent explicit effort_level write. The column + // must be set to the explicit value AND the sentinel must be removed. + let mut record = local_record(); + record.env_vars.insert( + "BUZZ_ACP_EFFORT_LEVEL".to_string(), + "old-sentinel".to_string(), + ); + apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "effort write must set the column" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "ACP sentinel in env_vars must be swept by the alias-removal step" + ); +} + +#[test] +fn effort_clear_sweeps_acp_sentinel_in_env_vars() { + // A concurrent clear (None inner value) plus a pre-existing ACP sentinel. + // After the clear the column is None and the sentinel is gone — no ambiguity. + let mut record = local_record(); + record.effort_level = Some("high".to_string()); + record.env_vars.insert( + "BUZZ_ACP_EFFORT_LEVEL".to_string(), + "old-sentinel".to_string(), + ); + apply_record_field_updates(&mut record, None, false, Some(None)) + .expect("local record must accept effort clear"); + assert_eq!( + record.effort_level, None, + "effort clear must zero the column" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "ACP sentinel in env_vars must be swept on clear" + ); +} + +// ── Helper disk-persistence contract ───────────────────────────────────────── +// +// This test drives the production helper sequence directly in its own body: +// load_managed_agents → apply_record_field_updates → stamp_record_updated_at +// → save_managed_agents → load-from-disk. +// +// Mutation proofs (scoped to this test body): +// - Removing `apply_record_field_updates` from this test body leaves +// `applied` undefined at `stamp_record_updated_at` — a compile error. +// - Removing the function call and stubbing the token manually leaves +// `effort_level` unchanged on disk — assertion fails (expected +// Some("high"), got None). +// +// Outer-seam gate: the compile error that prevents skipping +// `apply_record_field_updates` inside `update_managed_agent` is described in +// the outer-seam comment above (undefined `applied` token at the +// `stamp_record_updated_at` site). This test proves only the helper's own +// disk-roundtrip contract; it does not independently gate the production +// invocation. + +#[cfg(not(target_os = "windows"))] +#[test] +fn record_field_updates_persist_effort_to_disk() { + use crate::app_state::build_app_state; + use crate::managed_agents::{load_managed_agents, save_managed_agents}; + + // A single crate-wide process-env lock covers PATH, HOME, XDG_DATA_HOME, + // and all effort env keys — `lock_path_mutex` and `lock_env_mutex` both + // delegate to the same `PROCESS_ENV_MUTEX` static. + let _env_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + // RAII guards restore HOME and XDG_DATA_HOME on Drop (even on panic). + // Uses OsString so a pre-existing non-Unicode value is restored exactly. + struct EnvVarGuard { + key: String, + prior: Option, + } + impl EnvVarGuard { + fn set(key: &str, value: &std::path::Path) -> Self { + let prior = std::env::var_os(key); + #[allow(deprecated)] + // SAFETY: caller holds the crate-wide process-env lock. + unsafe { + std::env::set_var(key, value) + }; + Self { + key: key.to_string(), + prior, + } + } + } + impl Drop for EnvVarGuard { + fn drop(&mut self) { + #[allow(deprecated)] + // SAFETY: caller holds the crate-wide process-env lock. + unsafe { + match &self.prior { + Some(v) => std::env::set_var(&self.key, v), + None => std::env::remove_var(&self.key), + } + } + } + } + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_DATA_HOME", &home); + + let app = tauri::test::mock_builder() + .manage(build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app builds headless"); + + // Seed a local record with no effort set. + let seed: crate::managed_agents::ManagedAgentRecord = + serde_json::from_value(serde_json::json!({ + "pubkey": "test-effort-agent", + "name": "Effort Test Agent", + "relay_url": "", "acp_command": "", "agent_command": "", + "agent_args": [], "mcp_command": "", "turn_timeout_seconds": 0, + "system_prompt": null, "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 + })) + .unwrap(); + save_managed_agents(app.handle(), &[seed]).unwrap(); + + // Drive the production seam: load → apply_record_field_updates → + // stamp_record_updated_at → save. This is the exact sequence that + // `update_managed_agent` executes inside its locked transaction. + let mut records = load_managed_agents(app.handle()).unwrap(); + let record = records + .iter_mut() + .find(|r| r.pubkey == "test-effort-agent") + .expect("seeded record must load"); + let applied = apply_record_field_updates(record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + stamp_record_updated_at(record, applied); + save_managed_agents(app.handle(), &records).unwrap(); + + // Verify effort landed on disk. + let saved = load_managed_agents(app.handle()).unwrap(); + let saved_record = saved + .iter() + .find(|r| r.pubkey == "test-effort-agent") + .expect("agent must persist after update"); + assert_eq!( + saved_record.effort_level.as_deref(), + Some("high"), + "apply_record_field_updates + stamp_record_updated_at must write effort_level to disk" + ); + // _home_guard and _xdg_guard restore HOME and XDG_DATA_HOME via Drop. +} diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 6135c671606..1371abba2c6 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -13,10 +13,17 @@ use crate::{ #[tauri::command] pub fn set_agent_managed_profiles(enabled: bool, state: State<'_, AppState>) { state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .store(!enabled, Ordering::Release); } +#[tauri::command] +pub fn set_thread_scoped_acp_sessions(enabled: bool, state: State<'_, AppState>) { + state + .thread_scoped_acp_sessions_enabled() + .store(enabled, Ordering::Release); +} + #[tauri::command] pub async fn set_managed_agent_start_on_app_launch( pubkey: String, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index acee23f2f39..0ad7fd321c5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,16 +6,17 @@ use super::managed_agent_definition::validate_create_definition; use crate::{ app_state::AppState, managed_agents::{ + bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared}, build_managed_agent_summary, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, - managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary, + managed_agents_base_dir, normalize_agent_args, resolve_provider_binary, save_managed_agents, start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, - relay::{relay_ws_url_with_override, sync_managed_agent_profile}, + relay::relay_ws_url_with_override, util::now_iso, }; @@ -54,50 +55,9 @@ pub(super) fn summarize_from_disk( ) } -fn normalize_relay_mesh( - config: Option<&RelayMeshConfig>, - backend: &BackendKind, -) -> Result, String> { - let Some(config) = config else { - return Ok(None); - }; - - let model_ref = config.model_ref.trim(); - if model_ref.is_empty() { - return Err("Buzz shared compute model is required".to_string()); - } - if backend != &BackendKind::Local { - return Err("Buzz shared compute agents must use the local backend".to_string()); - } - - Ok(Some(RelayMeshConfig { - model_ref: model_ref.to_string(), - })) -} - -fn trim_to_optional_string(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn resolve_created_avatar_url( - requested_avatar_url: Option<&str>, - persona_avatar_url: Option, - agent_command: &str, -) -> Option { - requested_avatar_url - .and_then(trim_to_optional_string) - .or_else(|| { - persona_avatar_url - .as_deref() - .and_then(trim_to_optional_string) - }) - .or_else(|| managed_agent_avatar_url(agent_command)) -} +#[path = "agents_create_fields.rs"] +mod create_fields; +use create_fields::{normalize_relay_mesh, resolve_created_avatar_url, trim_to_optional_string}; #[cfg(feature = "mesh-llm")] async fn ensure_relay_mesh_for_record( @@ -207,6 +167,7 @@ pub(super) async fn start_local_agent_with_preflight( allow_fresh_create_start: bool, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, ) -> Result { let record_snapshot = { let _store_guard = state @@ -300,6 +261,7 @@ pub(super) async fn start_local_agent_with_preflight( &mut runtimes, Some(workspace_owner.as_str()), &workspace_relay_url, + replay_floor_unix, )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { @@ -486,7 +448,7 @@ pub async fn create_managed_agent( }; // ── Phase 3: save record (sync lock) ─────────────────────────────────────── - let (agent, resolved_avatar_url) = { + let (agent, resolved_avatar_url, profile_about) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -637,10 +599,10 @@ pub async fn create_managed_agent( input.parallelism, linked_persona.as_ref(), )?; - let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), + description: None, persona_id: requested_persona_id.clone(), team_id, private_key_nsec: private_key_nsec.clone(), @@ -739,16 +701,20 @@ pub async fn create_managed_agent( // before any .await — owner-authored, every agent (Will's ruling: no // is_builtin/persona-membership gate). retain_managed_agent_pending(&app, &state, record); + // Effective owner-authored description for the kind:0 `about`. + let profile_about = crate::managed_agents::record_effective_description(record, &personas); ( summarize_from_disk(&app, record, &runtimes)?, resolved_avatar_url, + profile_about, ) }; // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; let agent = if input.spawn_after_create && input.backend == BackendKind::Local { - match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await { + match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await + { Ok(agent) => agent, Err(error) => { let _store_guard = state @@ -781,20 +747,16 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. - let profile_relay_url = crate::relay::effective_agent_relay_url( - &resolved_relay_url, - &relay_ws_url_with_override(&state), - ); - let mut profile_sync_error = (sync_managed_agent_profile( + let mut profile_sync_error = profile::publish_agent_profile_with_about( &state, - &profile_relay_url, + &resolved_relay_url, &agent_keys, &name, resolved_avatar_url.as_deref(), + profile_about.as_deref(), auth_tag.as_deref(), ) - .await) - .err(); + .await; profile_sync_error = super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await; @@ -813,7 +775,7 @@ pub async fn create_managed_agent( build_deploy_payload(&app, &state, rec)? }; match deploy_to_provider( - &app, &state, &pubkey, id, config, agent_json, None, None, None, + &app, &state, &pubkey, id, config, agent_json, None, None, None, None, ) .await { @@ -861,6 +823,7 @@ pub async fn start_managed_agent( pubkey: String, expected_relay_url: Option, expected_signer_pubkey: Option, + replay_floor_unix: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -960,6 +923,7 @@ pub async fn start_managed_agent( false, expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), + replay_floor_unix, ) .await } @@ -972,6 +936,9 @@ pub async fn start_managed_agent( // against the payload rebuilt after the deploy lock — the exact // payload invoked — so a switch racing the lock wait cannot deploy // the agent into the new tenant on behalf of a stale callback. + // The replay floor rides along so a publish-first mention send's + // remote harness replays past the already-published message, same + // as the local spawn path. deploy_to_provider( &app, &state, @@ -982,6 +949,7 @@ pub async fn start_managed_agent( cached_binary_path.as_deref(), expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), + replay_floor_unix, ) .await?; @@ -1013,7 +981,7 @@ pub async fn start_managed_agent( // with no persisted avatar, this also backfills the avatar from the relay. if result.is_ok() && state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) { let reconcile_pubkey = pubkey.clone(); @@ -1088,6 +1056,20 @@ pub async fn stop_managed_agent( // Async so the blocking body (disk reads/writes, process termination, keyring // delete, nest regeneration) runs off the main UI thread via spawn_blocking. +fn run_managed_agent_deletion( + base_dir: &std::path::Path, + pubkey: &str, + records: &mut Vec, + delete: impl FnOnce(&mut Vec) -> Result, +) -> Result { + recover_pending_assignment_cleanup(base_dir, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; + with_agent_assignments_cleared(base_dir, pubkey, || delete(records)) +} + #[tauri::command] pub async fn delete_managed_agent( pubkey: String, @@ -1103,6 +1085,12 @@ pub async fn delete_managed_agent( .lock() .map_err(|error| error.to_string())?; let mut records = load_managed_agents(&app)?; + let base_dir = managed_agents_base_dir(&app)?; + recover_pending_assignment_cleanup(&base_dir, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; let mut runtimes = state .managed_agent_processes .lock() @@ -1136,16 +1124,17 @@ pub async fn delete_managed_agent( } } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - stop_managed_agent_process(&app, record, &mut runtimes)?; - } - state.clear_agent_session_caches(&pubkey); - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { + if !records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} not found")); } - save_managed_agents(&app, &records)?; + run_managed_agent_deletion(&base_dir, &pubkey, &mut records, |records| { + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + state.clear_agent_session_caches(&pubkey); + records.retain(|record| record.pubkey != pubkey); + save_managed_agents(&app, records) + })?; crate::managed_agents::delete_agent_key(&pubkey); // Tombstone after confirmed removal (inside lock; every published // agent tombstones). The NIP-IA kind:9035 archive request — which diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 34c06d25919..69c2d2f7f83 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -100,6 +100,7 @@ pub(crate) async fn reconcile_on_workspace_apply( cached_binary_path.as_deref(), None, None, + None, ) .await { diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index bb56a67eaa4..15db4dec5aa 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -6,7 +6,7 @@ use crate::{ app_state::AppState, managed_agents::{ discover_provider_candidates, load_managed_agents, provider_deploy, - resolve_provider_binary, save_managed_agents, BackendKind, + resolve_provider_binary, save_managed_agents, BackendKind, REPLAY_FLOOR_ENV_VAR, }, util::now_iso, }; @@ -31,6 +31,13 @@ use super::build_deploy_payload; /// deployment fails closed instead of deploying a stale start into the new /// tenant under the new tenant's owner identity. `None` preserves the /// unscoped behavior for callers without a tenant boundary. +/// +/// `replay_floor_unix`: optional unix-seconds replay floor from a +/// publish-first mention send. It is injected into the rebuilt payload's +/// `launch.policy_env` as `BUZZ_ACP_REPLAY_FLOOR`, so the remote harness's +/// startup watermark replays back past the already-published triggering +/// message exactly like a local spawn. Per-invocation only — never persisted +/// on the record, so later redeploys do not carry a stale floor. #[allow(clippy::too_many_arguments)] pub(crate) async fn deploy_to_provider( app: &AppHandle, @@ -42,6 +49,7 @@ pub(crate) async fn deploy_to_provider( _cached_binary_path: Option<&str>, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, ) -> Result<(), String> { let deploy_lock = { let mut locks = state @@ -58,7 +66,7 @@ pub(crate) async fn deploy_to_provider( // The payload may have waited behind another deployment. Rebuild it from // the current record so the final provider invocation always carries the // newest saved policy rather than the stale snapshot captured by its caller. - let (provider_id, config, cached_binary_path, agent_json) = { + let (provider_id, config, cached_binary_path, mut agent_json) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -83,6 +91,9 @@ pub(crate) async fn deploy_to_provider( // Assert the caller's captured scope against THIS payload — the exact // value invoked below — not the pre-lock snapshot its caller validated. assert_payload_scope(&agent_json, expected_relay_url, expected_signer_pubkey)?; + // The floor is invocation state, not record state, so the post-lock + // rebuild cannot restore it — inject it into the payload actually invoked. + apply_replay_floor(&mut agent_json, replay_floor_unix); // Resolve via discovered candidates only. Cached path must match BOTH // "is a discovered candidate" AND "belongs to this provider_id". A tampered // record cannot redirect deploys to a different provider's binary. @@ -159,6 +170,58 @@ fn assert_payload_scope( Ok(()) } +/// Inject a caller-supplied replay floor into the deploy payload so the +/// remote harness consumes it exactly like a local spawn: as the +/// [`REPLAY_FLOOR_ENV_VAR`] environment variable. The floor rides +/// `launch.policy_env` (tier 1); any same-named key in `launch.env` (tier 2) +/// is stripped because that tier later-wins and a persisted user value must +/// not shadow this send's floor — the remote mirror of +/// `apply_replay_floor_env`'s post-`descriptor.env` write on the local spawn. +/// With no caller floor the payload is left untouched — a user-supplied +/// `launch.env` value passes through, and plain redeploys never carry a stale +/// floor. +fn apply_replay_floor(agent_json: &mut serde_json::Value, replay_floor_unix: Option) { + let Some(floor) = replay_floor_unix else { + return; + }; + let Some(launch) = agent_json + .get_mut("launch") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + if let Some(env) = launch + .get_mut("env") + .and_then(serde_json::Value::as_object_mut) + { + let shadowed: Vec = env + .keys() + .filter(|key| key.eq_ignore_ascii_case(REPLAY_FLOOR_ENV_VAR)) + .cloned() + .collect(); + for key in shadowed { + env.remove(&key); + } + } + match launch + .get_mut("policy_env") + .and_then(serde_json::Value::as_object_mut) + { + Some(policy_env) => { + policy_env.insert( + REPLAY_FLOOR_ENV_VAR.to_string(), + serde_json::Value::String(floor.to_string()), + ); + } + None => { + launch.insert( + "policy_env".to_string(), + serde_json::json!({ (REPLAY_FLOOR_ENV_VAR): floor.to_string() }), + ); + } + } +} + fn policy_matches_payload( record: &crate::managed_agents::ManagedAgentRecord, deployed_agent_json: &serde_json::Value, @@ -283,6 +346,79 @@ mod tests { assert_payload_scope(&serde_json::json!({}), None, None).unwrap(); } + // ── apply_replay_floor: publish-first floor threading into the payload ── + + fn launch_payload() -> serde_json::Value { + serde_json::json!({ + "launch": { + "env": { "KEEP_ME": "yes" }, + "policy_env": { "BUZZ_ACP_LAZY_POOL": "true" }, + }, + }) + } + + #[test] + fn caller_replay_floor_rides_launch_policy_env() { + // A publish-first mention send's floor must reach the remote harness + // as BUZZ_ACP_REPLAY_FLOOR, exactly like a local spawn's env. + let mut payload = launch_payload(); + apply_replay_floor(&mut payload, Some(1_756_600_000)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "1756600000" + ); + assert_eq!(payload["launch"]["env"]["KEEP_ME"], "yes"); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_LAZY_POOL"], + "true" + ); + } + + #[test] + fn caller_replay_floor_strips_user_env_shadow() { + // launch.env later-wins over policy_env in the remote three-tier + // model; a persisted user floor must not shadow this send's floor. + let mut payload = launch_payload(); + payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"] = "1".into(); + payload["launch"]["env"]["buzz_acp_replay_floor"] = "2".into(); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "42" + ); + assert!(payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"].is_null()); + assert!(payload["launch"]["env"]["buzz_acp_replay_floor"].is_null()); + assert_eq!(payload["launch"]["env"]["KEEP_ME"], "yes"); + } + + #[test] + fn no_caller_floor_leaves_payload_untouched() { + // Create-flow deploys and plain redeploys carry no floor: user env + // passthrough stands and no stale floor is invented. + let mut payload = launch_payload(); + payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"] = "1".into(); + let before = payload.clone(); + apply_replay_floor(&mut payload, None); + assert_eq!(payload, before); + } + + #[test] + fn replay_floor_tolerates_payload_without_launch() { + let mut payload = serde_json::json!({}); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!(payload, serde_json::json!({})); + } + + #[test] + fn replay_floor_creates_missing_policy_env() { + let mut payload = serde_json::json!({ "launch": {} }); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "42" + ); + } + #[test] fn successful_deploy_acknowledges_pending_policy() { let mut record = record(); diff --git a/desktop/src-tauri/src/commands/agents_create_fields.rs b/desktop/src-tauri/src/commands/agents_create_fields.rs new file mode 100644 index 00000000000..16f840ba2e8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_create_fields.rs @@ -0,0 +1,49 @@ +//! Field normalization for `create_managed_agent` — the pure validators and +//! resolvers its request-to-record mapping runs before any side effect. + +use crate::managed_agents::{managed_agent_avatar_url, BackendKind, RelayMeshConfig}; + +pub(super) fn normalize_relay_mesh( + config: Option<&RelayMeshConfig>, + backend: &BackendKind, +) -> Result, String> { + let Some(config) = config else { + return Ok(None); + }; + + let model_ref = config.model_ref.trim(); + if model_ref.is_empty() { + return Err("Buzz shared compute model is required".to_string()); + } + if backend != &BackendKind::Local { + return Err("Buzz shared compute agents must use the local backend".to_string()); + } + + Ok(Some(RelayMeshConfig { + model_ref: model_ref.to_string(), + })) +} + +pub(super) fn trim_to_optional_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub(super) fn resolve_created_avatar_url( + requested_avatar_url: Option<&str>, + persona_avatar_url: Option, + agent_command: &str, +) -> Option { + requested_avatar_url + .and_then(trim_to_optional_string) + .or_else(|| { + persona_avatar_url + .as_deref() + .and_then(trim_to_optional_string) + }) + .or_else(|| managed_agent_avatar_url(agent_command)) +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 06f57b1dc52..de8ca8cc789 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -43,16 +43,17 @@ pub(crate) fn resolve_deploy_model_provider( /// Serialize the portable launch contract shared with provider-backed agents. /// -/// `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( +/// `descriptor.env` is the authoritative six-layer environment for ordinary +/// values. Desktop-owned settings are reserved, stripped from that layer, and +/// emitted through `policy_env` so local and provider launches agree. +fn build_launch_block_for_policy( record: &ManagedAgentRecord, descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, teams: &[crate::managed_agents::TeamRecord], effective_prompt: Option<&str>, effective_model: Option<&str>, owner_pubkey: &str, + session_policy: crate::managed_agents::AcpSessionPolicy, ) -> serde_json::Value { use crate::managed_agents::{ known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, @@ -78,6 +79,7 @@ pub(super) fn build_launch_block( "BUZZ_ACP_AGENTS".into(), crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism), ); + crate::managed_agents::insert_acp_session_policy_env(&mut policy_env, session_policy); if let Some(value) = effective_prompt { policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); @@ -96,13 +98,13 @@ pub(super) fn build_launch_block( }; policy_env.insert(model_key.into(), value.to_string()); } - // I-4: remote parity for persisted startup effort. Mirrors the local spawn - // path in runtime.rs. The harness reads BUZZ_ACP_EFFORT_LEVEL into - // PoolStartup.startup_effort and applies it at first session creation via - // resolve_startup_effort(). - if let Some(ref value) = record.effort_level { - policy_env.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.clone()); - } + // Startup effort needs no remote-specific handling: the harness-agnostic + // effort projection already ran inside `resolve_effective_harness_descriptor`, + // so `descriptor.env` (→ `launch.env`, tier 2) carries exactly one effort key + // holding the effective value, with every foreign/legacy/transport effort key + // stripped. Tier 2 later-wins over `policy_env` (tier 1) and no authoritative + // tier-3 key collides with an effort key, so the projected value reaches the + // remote pod verbatim — identical authority to the local spawn. if let Some(value) = record.idle_timeout_seconds { policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); } @@ -119,14 +121,6 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } - // B5 remote parity: when a canonical effort_level is persisted, strip - // BUZZ_ACP_EFFORT_LEVEL from launch.env so it cannot shadow the canonical - // value in policy_env (tier 1). In the k8s three-tier model tier 2 - // (launch.env) overwrites tier 1 (policy_env) — later-wins — so the key - // must be absent from tier 2 whenever a canonical value is present. - // When effort_level is None there is no canonical to protect, so user - // env passthrough stands (env may legitimately seed startup effort). - // // B2 remote parity: mirror the local A1 model authority. For a Claude // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL @@ -136,9 +130,13 @@ pub(super) fn build_launch_block( // canonical model. When no canonical model is present, neither key is in // policy_env, so stripping them keeps the remote process free of both — // matching local, where `apply_claude_model_env(None)` removes both. + // + // Effort keys need no stripping here: the projection already reduced + // `descriptor.env` to exactly one effort key holding the effective value, + // so launch.env carries the authority directly (see the effort note above). let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); let strip_key = |k: &str| { - (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) + k.eq_ignore_ascii_case(crate::managed_agents::ACP_SESSION_POLICY_ENV_VAR) || (is_claude && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) @@ -159,6 +157,26 @@ pub(super) fn build_launch_block( }) } +#[cfg(test)] +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 { + build_launch_block_for_policy( + record, + descriptor, + teams, + effective_prompt, + effective_model, + owner_pubkey, + crate::managed_agents::AcpSessionPolicy::Channel, + ) +} + 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( @@ -198,13 +216,14 @@ pub(crate) fn build_deploy_payload( 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( + let launch = build_launch_block_for_policy( record, &descriptor, &teams, effective.system_prompt.value.as_deref(), effective.model.value.as_deref(), &owner_pubkey, + crate::managed_agents::acp_session_policy(state), ); let effective_parallelism = @@ -342,9 +361,40 @@ mod tests { 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["policy_env"]["BUZZ_ACP_SESSION_POLICY"], "channel"); assert_eq!(launch["owner_pubkey"], "owner-hex"); } + #[test] + fn launch_block_thread_policy_is_authoritative_and_preserves_unrelated_env() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([ + ("BUZZ_ACP_SESSION_POLICY".to_string(), "channel".to_string()), + ("KEEP_ME".to_string(), "yes".to_string()), + ]), + }; + + let launch = build_launch_block_for_policy( + &record, + &descriptor, + &[], + None, + None, + "owner-hex", + crate::managed_agents::AcpSessionPolicy::Thread, + ); + + assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_POLICY"], "thread"); + assert!( + launch["env"]["BUZZ_ACP_SESSION_POLICY"].is_null(), + "desktop policy must not be shadowed by descriptor env" + ); + assert_eq!(launch["env"]["KEEP_ME"], "yes"); + } + #[test] fn launch_block_claude_runtime_uses_anthropic_model_not_buzz_acp_model() { // B2: remote claude deploys must send ANTHROPIC_MODEL, not BUZZ_ACP_MODEL, @@ -467,19 +517,27 @@ mod tests { } #[test] - fn launch_block_claude_runtime_injects_effort_level_when_set() { - // I-4: remote parity — record.effort_level → BUZZ_ACP_EFFORT_LEVEL in policy_env. - let mut record = record(); - record.effort_level = Some("high".to_string()); + fn launch_block_claude_runtime_carries_projected_effort_in_launch_env() { + // Under the harness-agnostic projection, effort no longer rides + // policy_env: `resolve_effective_harness_descriptor` reduces + // `descriptor.env` to exactly one effort key (for a keyless claude + // runtime, the ACP sentinel) holding the effective value, and + // build_launch_block passes that env through to launch.env verbatim. + let record = record(); let descriptor = EffectiveHarnessDescriptor { command: "claude".into(), args: vec![], - env: BTreeMap::new(), + // The single projected effort key the descriptor resolver emits. + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "high".to_string())]), }; let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "claude remote must receive BUZZ_ACP_EFFORT_LEVEL when effort_level is set" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "the projected effort key must survive into launch.env" + ); + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is not a policy_env value under the projection design" ); } @@ -506,26 +564,35 @@ mod tests { /// authoritative. #[test] fn launch_block_canonical_effort_strips_user_env_collision() { + // Remote parity for the authority collision: the canonical column and a + // conflicting user `BUZZ_ACP_EFFORT_LEVEL` both present. The projection + // (run inside `resolve_effective_harness_descriptor`) resolves it — + // canonical `high` wins over the user `low` transport sentinel — and + // build_launch_block carries exactly that one value into launch.env, + // identical to the local spawn path. let mut record = record(); + record.runtime = Some("claude".into()); record.effort_level = Some("high".to_string()); - let descriptor = EffectiveHarnessDescriptor { - command: "claude".into(), - args: vec![], - // User-supplied conflicting value in descriptor.env. - env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), - }; + record + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &record, + &[], + &Default::default(), + ) + .expect("claude descriptor resolves"); let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); - // Canonical must be in policy_env (tier 1). + // The projected canonical authority is the single effort value carried. assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "canonical effort must be in policy_env when record.effort_level is Some" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "canonical effort must win the collision and reach launch.env" ); - // Conflicting user value must be absent from launch.env (tier 2) so it - // cannot shadow the canonical tier-1 value in build_env. + // Effort is not a policy_env value under the projection design. assert!( - launch["env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), - "user BUZZ_ACP_EFFORT_LEVEL must be stripped from launch.env when canonical is present" + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is carried in launch.env, never policy_env" ); } diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 16a1538c753..66d2ae27493 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -40,6 +40,11 @@ pub(crate) struct ProfileReconcileData { /// backfill to recover the correct avatar from the persona record when the /// relay profile has been corrupted. pub(crate) persona_id: Option, + /// Expected kind:0 `about` — the agent's effective public description + /// (owner-authored when present; see + /// `managed_agents::record_effective_description`). `None` publishes an + /// about-less profile. + pub(crate) about: Option, } /// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no @@ -96,6 +101,7 @@ pub(crate) fn profile_reconcile_data( pubkey: record.pubkey.clone(), agent_command: crate::managed_agents::record_agent_command(record, personas), persona_id: record.persona_id.clone(), + about: crate::managed_agents::record_effective_description(record, personas), } } @@ -200,7 +206,7 @@ pub(crate) async fn reconcile_agent_profile( ); if !state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) { return Ok(ProfileReconcileOutcome::SkippedDisabled); @@ -254,7 +260,12 @@ pub(crate) async fn reconcile_agent_profile( Some(expected_avatar) }; - if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { + if !profile_needs_sync( + existing.as_ref(), + &data.name, + expected_avatar.as_deref(), + data.about.as_deref(), + ) { return Ok(ProfileReconcileOutcome::Reconciled); } @@ -262,7 +273,7 @@ pub(crate) async fn reconcile_agent_profile( .map_err(|e| format!("failed to parse agent keys: {e}"))?; if !state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) { return Ok(ProfileReconcileOutcome::SkippedDisabled); @@ -274,6 +285,7 @@ pub(crate) async fn reconcile_agent_profile( &agent_keys, &data.name, expected_avatar.as_deref(), + data.about.as_deref(), data.auth_tag.as_deref(), ) .await?; @@ -281,23 +293,84 @@ pub(crate) async fn reconcile_agent_profile( } /// Decide whether a published profile is missing or stale relative to the -/// expected name and avatar. A missing profile always needs sync; a present -/// one is stale when either the display name or picture diverges. +/// expected name, avatar, and about. A missing profile always needs sync; a +/// present one is stale when the display name, picture, or about diverges. +/// For about, `None` and the empty string are treated as equal so an +/// about-less profile never triggers a pointless republish loop. pub(super) fn profile_needs_sync( existing: Option<&crate::relay::AgentProfileInfo>, expected_name: &str, expected_avatar: Option<&str>, + expected_about: Option<&str>, ) -> bool { match existing { None => true, Some(info) => { let name_matches = info.display_name.as_deref() == Some(expected_name); let picture_matches = info.picture.as_deref() == expected_avatar; - !name_matches || !picture_matches + let about_matches = info.about.as_deref().unwrap_or("") == expected_about.unwrap_or(""); + !name_matches || !picture_matches || !about_matches } } } +/// Publish a managed agent's kind:0 profile with the authored public +/// description as `about`, resolving the effective +/// relay URL from the record's stored value. Returns the sync error (if any) +/// rather than failing the caller — profile publish is best-effort in the +/// create and snapshot-import flows that share this helper. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn publish_agent_profile_with_about( + state: &AppState, + record_relay_url: &str, + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + about: Option<&str>, + auth_tag: Option<&str>, +) -> Option { + let relay_url = crate::relay::effective_agent_relay_url( + record_relay_url, + &relay_ws_url_with_override(state), + ); + crate::relay::sync_managed_agent_profile( + state, + &relay_url, + agent_keys, + display_name, + avatar_url, + about, + auth_tag, + ) + .await + .err() +} + +/// Publish a fresh persona-backed agent's kind:0 profile, computing the +/// effective public `about` from the persona itself. +/// Shared by flows in files at the size ratchet (snapshot import). +pub(crate) async fn publish_persona_profile( + state: &AppState, + record_relay_url: &str, + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + persona: &crate::managed_agents::AgentDefinition, + auth_tag: Option<&str>, +) -> Option { + let about = crate::managed_agents::effective_agent_description(persona.description.as_deref()); + publish_agent_profile_with_about( + state, + record_relay_url, + agent_keys, + display_name, + avatar_url, + about.as_deref(), + auth_tag, + ) + .await +} + // Async so the blocking body (disk reads/writes + process termination) runs off // the main UI thread via spawn_blocking. State is re-derived from the owned // AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send). diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 17fadea82f3..59e04b09ff0 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -9,6 +9,7 @@ fn bare_agent_record( use crate::managed_agents::{BackendKind, RespondTo}; use std::collections::BTreeMap; ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), @@ -70,6 +71,7 @@ fn bare_agent_record( fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { use std::collections::BTreeMap; AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Persona".to_string(), avatar_url: None, @@ -186,6 +188,45 @@ fn deploy_resolver_inherits_global_when_definition_blank() { ); } +#[test] +fn production_delete_orchestration_restores_bestie_when_agent_save_fails() { + use crate::managed_agents::{ + bestie_assignment::{assignment_matches, replace_assignment}, + retention::open_retention_db, + }; + + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + std::fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let db_path = retention_dir.join("owner.db"); + let pubkey = "a".repeat(64); + replace_assignment( + &mut open_retention_db(&db_path) + .unwrap_or_else(|error| panic!("open assignment DB: {error}")), + &pubkey, + ) + .unwrap_or_else(|error| panic!("seed assignment: {error}")); + let mut record = bare_agent_record(None, None, None); + record.pubkey.clone_from(&pubkey); + let mut records = vec![record]; + + let result = run_managed_agent_deletion(dir.path(), &pubkey, &mut records, |_records| { + Err::<(), _>("injected managed-agent save failure".to_string()) + }); + + assert_eq!( + result, + Err("injected managed-agent save failure".to_string()) + ); + assert!(assignment_matches( + &open_retention_db(&db_path) + .unwrap_or_else(|error| panic!("reopen assignment DB: {error}")), + &pubkey, + ) + .unwrap_or_else(|error| panic!("read restored assignment: {error}"))); +} + /// Deploy resolver falls back to global when both definition and record have none. #[test] fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() { @@ -314,15 +355,29 @@ fn created_avatar_uses_command_fallback_without_input_or_persona() { } fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProfileInfo { + profile_with_about(name, picture, None) +} + +fn profile_with_about( + name: Option<&str>, + picture: Option<&str>, + about: Option<&str>, +) -> crate::relay::AgentProfileInfo { crate::relay::AgentProfileInfo { display_name: name.map(str::to_string), picture: picture.map(str::to_string), + about: about.map(str::to_string), } } #[test] fn profile_needs_sync_when_missing() { - assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); + assert!(profile_needs_sync( + None, + "Duncan", + Some("https://x/a.png"), + None + )); } // ── resolve_reconcile_relay: deferred-task relay pinning ──────────────────── @@ -352,7 +407,7 @@ fn unpinned_reconcile_relay_resolves_the_execution_time_workspace() { #[test] fn profile_needs_sync_when_missing_even_without_expected_avatar() { - assert!(profile_needs_sync(None, "Duncan", None)); + assert!(profile_needs_sync(None, "Duncan", None, None)); } #[test] @@ -361,7 +416,8 @@ fn profile_needs_sync_when_name_diverges() { assert!(profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/a.png") + Some("https://x/a.png"), + None )); } @@ -371,7 +427,8 @@ fn profile_needs_sync_when_picture_diverges() { assert!(profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/new.png") + Some("https://x/new.png"), + None )); } @@ -381,14 +438,15 @@ fn profile_in_sync_when_name_and_picture_match() { assert!(!profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/a.png") + Some("https://x/a.png"), + None )); } #[test] fn profile_in_sync_when_both_avatars_absent() { let existing = profile(Some("Duncan"), None); - assert!(!profile_needs_sync(Some(&existing), "Duncan", None)); + assert!(!profile_needs_sync(Some(&existing), "Duncan", None, None)); } #[test] @@ -398,13 +456,50 @@ fn profile_needs_sync_when_existing_name_is_none() { Some(&existing), "Duncan", Some("https://x/a.png"), + None, )); } #[test] fn profile_needs_sync_when_expected_avatar_absent_but_published() { let existing = profile(Some("Duncan"), Some("https://x/a.png")); - assert!(profile_needs_sync(Some(&existing), "Duncan", None)); + assert!(profile_needs_sync(Some(&existing), "Duncan", None, None)); +} + +#[test] +fn profile_needs_sync_when_about_diverges() { + let existing = profile_with_about(Some("Duncan"), None, Some("Old description.")); + assert!(profile_needs_sync( + Some(&existing), + "Duncan", + None, + Some("New description.") + )); +} + +#[test] +fn profile_needs_sync_when_expected_about_absent_but_published() { + let existing = profile_with_about(Some("Duncan"), None, Some("Stale description.")); + assert!(profile_needs_sync(Some(&existing), "Duncan", None, None)); +} + +#[test] +fn profile_in_sync_when_about_matches() { + let existing = profile_with_about(Some("Duncan"), None, Some("A helpful desktop agent.")); + assert!(!profile_needs_sync( + Some(&existing), + "Duncan", + None, + Some("A helpful desktop agent.") + )); +} + +#[test] +fn profile_in_sync_when_about_none_equals_published_empty_string() { + // None vs "" must be treated as equal — otherwise every reconcile of an + // about-less agent would republish forever. + let existing = profile_with_about(Some("Duncan"), None, Some("")); + assert!(!profile_needs_sync(Some(&existing), "Duncan", None, None)); } #[test] diff --git a/desktop/src-tauri/src/commands/bestie.rs b/desktop/src-tauri/src/commands/bestie.rs new file mode 100644 index 00000000000..19f21186258 --- /dev/null +++ b/desktop/src-tauri/src/commands/bestie.rs @@ -0,0 +1,218 @@ +use std::sync::atomic::Ordering; + +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + bestie_assignment::{ + assignment_matches, clear_assignment, get_assignment, + recover_pending_assignment_cleanup, replace_assignment, BestieAssignment, + }, + load_managed_agents, managed_agents_base_dir, + retention::{active_retention_scope, open_retention_db, RetentionScope}, + BackendKind, ManagedAgentRecord, + }, + models::ChannelInfo, +}; + +fn canonical_relay(relay_url: &str) -> Result { + buzz_core_pkg::relay::normalize_relay_url(relay_url).map_err(|error| error.to_string()) +} + +fn assert_expected_scope( + scope: &RetentionScope, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, +) -> Result<(), String> { + if let Some(expected) = expected_relay_url { + if canonical_relay(expected)? != canonical_relay(&scope.relay_url)? { + return Err("active community changed while resolving Bestie".to_string()); + } + } + if let Some(expected) = expected_signer_pubkey { + if expected.trim().to_ascii_lowercase() != scope.owner_keys.public_key().to_hex() { + return Err("active identity changed while resolving Bestie".to_string()); + } + } + Ok(()) +} + +fn validate_agent_pubkey(pubkey: &str) -> Result { + let normalized = pubkey.trim().to_ascii_lowercase(); + if normalized.len() != 64 + || !normalized + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("Bestie agent pubkey must be 64 hexadecimal characters".to_string()); + } + Ok(normalized) +} + +fn require_eligible_local_agent( + records: &[ManagedAgentRecord], + pubkey: &str, +) -> Result<(), String> { + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(pubkey)) + .ok_or_else(|| "assigned Bestie agent no longer exists on this device".to_string())?; + if record.backend != BackendKind::Local { + return Err("only a local managed agent can be your Bestie".to_string()); + } + Ok(()) +} + +fn recover_pending_cleanup(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { + recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + }) +} + +#[tauri::command] +pub fn get_bestie_assignment( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let conn = open_retention_db(&scope.db_path)?; + get_assignment(&conn) +} + +#[tauri::command] +pub fn assign_bestie( + agent_pubkey: String, + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let pubkey = validate_agent_pubkey(&agent_pubkey)?; + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + require_eligible_local_agent(&records, &pubkey)?; + let mut conn = open_retention_db(&scope.db_path)?; + replace_assignment(&mut conn, &pubkey) +} + +#[tauri::command] +pub fn clear_bestie_assignment( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let mut conn = open_retention_db(&scope.db_path)?; + clear_assignment(&mut conn) +} + +#[tauri::command] +pub async fn resolve_bestie_conversation( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let generation = state.workspace_apply_generation.load(Ordering::Acquire); + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let assignment = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let conn = open_retention_db(&scope.db_path)?; + let assignment = get_assignment(&conn)? + .ok_or_else(|| "choose an agent before opening Bestie".to_string())?; + require_eligible_local_agent(&records, &assignment.agent_pubkey)?; + assignment + }; + + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let channel = super::dms::open_dm_with_scope( + vec![assignment.agent_pubkey.clone()], + Some(&scope.relay_url), + Some(&owner_pubkey), + &state, + ) + .await?; + + if state.workspace_apply_generation.load(Ordering::Acquire) != generation { + return Err("active workspace changed while resolving Bestie".to_string()); + } + let current_scope = active_retention_scope(&app, &state)?; + assert_expected_scope(¤t_scope, Some(&scope.relay_url), Some(&owner_pubkey))?; + let conn = open_retention_db(&scope.db_path)?; + if !assignment_matches(&conn, &assignment.agent_pubkey)? { + return Err("Bestie assignment changed while opening the conversation".to_string()); + } + Ok(channel) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_scope_accepts_runtime_equivalences() { + assert_eq!( + canonical_relay(" WSS://LOCALHOST:443/ ") + .unwrap_or_else(|error| panic!("canonical relay: {error}")), + "wss://127.0.0.1" + ); + } + + #[test] + fn pubkeys_are_normalized_and_validated() { + assert_eq!( + validate_agent_pubkey(&"A".repeat(64)) + .unwrap_or_else(|error| panic!("valid pubkey: {error}")), + "a".repeat(64) + ); + assert!(validate_agent_pubkey("short").is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index 5f6ca279802..252068c94d7 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -23,6 +23,21 @@ pub async fn open_dm( expected_relay_url: Option, expected_signer_pubkey: Option, state: State<'_, AppState>, +) -> Result { + open_dm_with_scope( + pubkeys, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + &state, + ) + .await +} + +pub(crate) async fn open_dm_with_scope( + pubkeys: Vec, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, + state: &AppState, ) -> Result { // Resolve the relay AND the signing identity once for the open + metadata // read pair. Callers with a captured tenant scope (Projects agent sends) @@ -34,25 +49,22 @@ pub async fn open_dm( // tenant-A DM signed as tenant B's identity — fail closed instead, and // use this exact key snapshot for both the event signature and the // NIP-98 auth of every request in this command. - let api_base_url = crate::relay::relay_api_base_url_with_override(&state); - assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?; + let api_base_url = crate::relay::relay_api_base_url_with_override(state); + assert_expected_relay_scope(expected_relay_url, &api_base_url)?; let keys = state.signing_keys()?; - assert_expected_signer( - expected_signer_pubkey.as_deref(), - &keys.public_key().to_hex(), - )?; + assert_expected_signer(expected_signer_pubkey, &keys.public_key().to_hex())?; // Submit a kind:41010 dm-open event; the relay replies with the channel id // in its OK message payload. let builder = events::build_dm_open(&pubkeys)?; - let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?; + let result = submit_event_at_with_keys(builder, state, &api_base_url, &keys).await?; let ack: OpenDmAck = parse_command_response(&result.message)?; // Re-fetch the channel metadata so the frontend gets the same `ChannelInfo` // shape as `get_channel_details` — through the same scope-checked base and // the same pinned identity. let metadata = query_relay_at_with_keys( - &state, + state, &api_base_url, &[serde_json::json!({ "kinds": [39000], diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index 0cc5679bf7b..bf66b761d32 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -336,14 +336,38 @@ pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await } +/// How long a fetched NIP-11 `self` pubkey stays valid in +/// [`AppState::relay_self_cache`]. The relay's signing identity changes only +/// on an operator-driven key rotation, so minutes of staleness are safe; the +/// TTL exists so even that rare rotation converges without an app restart. +pub(crate) const RELAY_SELF_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300); + +/// Read a still-fresh cached `self` pubkey for `relay_url`, if any. Fails open +/// (cache miss) on a poisoned lock — the fetch path never depends on the cache. +fn cached_relay_self(state: &AppState, relay_url: &str) -> Option { + let cache = state.relay_self_cache.lock().ok()?; + let (fetched_at, relay_self) = cache.get(relay_url)?; + (fetched_at.elapsed() < RELAY_SELF_CACHE_TTL).then(|| relay_self.clone()) +} + /// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL /// instead of re-resolving the workspace override. Used by /// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot /// query belong to the same captured relay target. +/// +/// Successful lookups are cached per relay URL for [`RELAY_SELF_CACHE_TTL`]: +/// send-time agent revalidation calls this on every agent-mention send, and +/// the uncached GET was a measurable slice of that latency. Only a verified +/// `Some` is cached — `Ok(None)` covers transient states (non-2xx status, a +/// document momentarily missing `self`) that must be re-tried, not pinned. pub(crate) async fn fetch_relay_self_at( state: &AppState, relay_url: &str, ) -> Result, String> { + if let Some(cached) = cached_relay_self(state, relay_url) { + return Ok(Some(cached)); + } + let http_url = relay_http_base_url(relay_url); let response = state .http_client @@ -367,6 +391,12 @@ pub(crate) async fn fetch_relay_self_at( }; if relay_self.len() == 64 && relay_self.chars().all(|c| c.is_ascii_hexdigit()) { + if let Ok(mut cache) = state.relay_self_cache.lock() { + cache.insert( + relay_url.to_string(), + (std::time::Instant::now(), relay_self.clone()), + ); + } Ok(Some(relay_self)) } else { Ok(None) @@ -476,7 +506,6 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - #[cfg(not(target_os = "windows"))] use std::sync::atomic::{AtomicUsize, Ordering}; /// Counting [`NestRegenTrigger`] double: records how many times the core @@ -575,6 +604,107 @@ mod tests { ); } + /// Spawn a loopback NIP-11 endpoint that counts hits and serves `self_hex` + /// (or a bare 503 when `self_hex` is `None`). Returns the `ws://` base and + /// the shared hit counter. + async fn spawn_nip11_relay(self_hex: Option) -> (String, std::sync::Arc) { + use axum::{http::StatusCode, routing::get, Json, Router}; + + let hits = std::sync::Arc::new(AtomicUsize::new(0)); + let route_hits = hits.clone(); + let router = Router::new().route( + "/", + get(move || { + let self_hex = self_hex.clone(); + let route_hits = route_hits.clone(); + async move { + route_hits.fetch_add(1, Ordering::SeqCst); + match self_hex { + Some(self_hex) => Ok(Json(serde_json::json!({ "self": self_hex }))), + None => Err(StatusCode::SERVICE_UNAVAILABLE), + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + (format!("ws://{addr}"), hits) + } + + /// The send path revalidates agent mentions on every agent-mention send, + /// and each pass used to re-GET the NIP-11 document. A second lookup + /// within the TTL must be served from [`AppState::relay_self_cache`] + /// without touching the relay. RED-on-revert: drop the `cached_relay_self` + /// check and the hit counter reads 2. + #[tokio::test] + async fn relay_self_second_fetch_within_ttl_is_served_from_cache() { + let self_hex = Keys::generate().public_key().to_hex(); + let (relay_url, hits) = spawn_nip11_relay(Some(self_hex.clone())).await; + let state = crate::app_state::build_app_state(); + + let first = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + let second = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + + assert_eq!(first.as_deref(), Some(self_hex.as_str())); + assert_eq!(second.as_deref(), Some(self_hex.as_str())); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "the second in-TTL lookup must not re-GET the NIP-11 document" + ); + } + + /// A non-success NIP-11 response yields `Ok(None)` and MUST stay + /// retryable: caching the outage would blank the agent directory (and + /// every send-time revalidation) for the full TTL after one relay blip. + #[tokio::test] + async fn relay_self_non_success_response_is_not_cached() { + let (relay_url, hits) = spawn_nip11_relay(None).await; + let state = crate::app_state::build_app_state(); + + assert_eq!(fetch_relay_self_at(&state, &relay_url).await.unwrap(), None); + assert_eq!(fetch_relay_self_at(&state, &relay_url).await.unwrap(), None); + assert_eq!( + hits.load(Ordering::SeqCst), + 2, + "a failed lookup must retry the relay, never pin the outage" + ); + } + + /// An entry older than [`RELAY_SELF_CACHE_TTL`] must be refetched so a + /// relay-side key rotation converges without an app restart. + #[tokio::test] + async fn relay_self_expired_cache_entry_is_refetched() { + let self_hex = Keys::generate().public_key().to_hex(); + let (relay_url, hits) = spawn_nip11_relay(Some(self_hex.clone())).await; + let state = crate::app_state::build_app_state(); + // `Instant` is opaque, so expiry is staged by planting an already-stale + // entry rather than sleeping through the TTL. Skip (vacuous pass) if + // the platform clock cannot represent an instant that far back. + let Some(stale_instant) = std::time::Instant::now() + .checked_sub(RELAY_SELF_CACHE_TTL + std::time::Duration::from_secs(1)) + else { + return; + }; + state + .relay_self_cache + .lock() + .unwrap() + .insert(relay_url.clone(), (stale_instant, "b".repeat(64))); + + let refreshed = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + + assert_eq!(refreshed.as_deref(), Some(self_hex.as_str())); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "an expired entry must be refetched from the relay" + ); + } + /// Spec test-vector regression for gotcha #3: the NIP-OA preimage subject /// is the *target/agent* pubkey, not the request signer. The vectors in /// `docs/nips/NIP-IA.md` §Test Vectors fix concrete values; verifying the diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 8da845c07d4..8cf8cc41747 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -8,12 +8,16 @@ use tokio_util::sync::CancellationToken; use crate::app_state::AppState; use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message}; +use super::media_filename::sanitize_filename; use super::media_transcode::{ has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, 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}; +use super::media_voice_note::{ + is_voice_note_filename, prepare_voice_note_for_upload, voice_note_mp4_filename, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -134,24 +138,6 @@ const BLOCKED_MIME: &[&str] = &[ "application/x-apple-diskimage", ]; -/// Sanitize a filename for use as a display label in the imeta `filename` field. -/// -/// Strips any directory components (keeps only the final path segment), removes -/// control characters, and bounds length to 255. Mirrors the relay's filename -/// validation so a sanitized name always passes ingest. Returns a fallback when -/// the result would be empty. -pub(crate) fn sanitize_filename(name: &str) -> String { - // Keep only the final path segment — defend against `../` and absolute paths - // regardless of separator style. - let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); - let cleaned: String = base.chars().filter(|c| !c.is_control()).take(255).collect(); - if cleaned.is_empty() { - "file".to_string() - } else { - cleaned - } -} - /// Return true when a PNG/WebP payload declares animation. /// /// Animated payloads use structural sanitizers so frame timing, looping, and @@ -724,8 +710,12 @@ pub(super) async fn upload_media_bytes_inner( let heic_by_extension = filename .as_deref() .is_some_and(|name| has_heic_extension(std::path::Path::new(name))); + let is_voice_note = is_voice_note_filename(filename.as_deref()); - let (body, poster_bytes) = if is_video_file(&data) { + let (body, poster_bytes) = if is_voice_note { + emit_media_upload_phase(&app, progress_id.as_deref(), "processing-audio"); + prepare_voice_note_for_upload(data, cancellation).await? + } else 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. @@ -790,7 +780,14 @@ pub(super) async fn upload_media_bytes_inner( } } - descriptor.filename = filename.as_deref().map(sanitize_filename); + descriptor.filename = filename.as_deref().map(|name| { + let upload_name = if is_voice_note { + voice_note_mp4_filename(name) + } else { + name.to_string() + }; + sanitize_filename(&upload_name) + }); Ok(descriptor) } @@ -981,18 +978,4 @@ mod tests { reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE )); } - - #[test] - fn test_sanitize_filename() { - assert_eq!(sanitize_filename("report.pdf"), "report.pdf"); - // Strips directory components and traversal. - assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); - assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt"); - assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx"); - // Empty / separator-only falls back. - assert_eq!(sanitize_filename(""), "file"); - assert_eq!(sanitize_filename("/"), "file"); - // Control chars removed. - assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt"); - } } diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 7bc94da25d2..54d0052e5a2 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -1,11 +1,13 @@ use futures_util::StreamExt; use sha2::{Digest, Sha256}; use tauri::State; +use tokio_util::sync::CancellationToken; use crate::app_state::AppState; use crate::commands::clipboard::with_clipboard; 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::media::{detect_and_validate_mime, mint_media_get_auth}; +use crate::commands::media_filename::sanitize_filename; use crate::commands::{ personas::{ parse_snapshot_payload_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, @@ -18,7 +20,7 @@ use crate::commands::{ use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message}; /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. -const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +pub(super) const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; /// Download request timeout. const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); @@ -29,7 +31,7 @@ const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60) /// - URL scheme is `https` (or `http` for localhost dev) /// - URL origin matches the relay base URL /// - URL path matches `/media/{hash}.{ext}` -fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { +pub(super) fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { let parsed = url::Url::parse(url).map_err(|_| "invalid URL".to_string())?; let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL".to_string())?; @@ -139,32 +141,6 @@ pub async fn download_file( save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await } -/// Fetch relay media bytes for the composer image editor. -/// -/// The editor composites the image onto a canvas and needs pixel access. -/// Handing the webview raw bytes over IPC (which it wraps in a same-origin -/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and -/// therefore without any media-proxy header or origin-gate changes. -/// -/// Same SSRF validation, size cap, and content policy as the download -/// commands above. -/// -/// Returns `tauri::ipc::Response` so the bytes cross IPC as a raw buffer -/// instead of a JSON number array (which would be ~3x the size to -/// serialize and deserialize at the 50 MiB cap). -#[tauri::command] -pub async fn fetch_media_bytes( - url: String, - state: State<'_, AppState>, -) -> Result { - let relay_base = relay_api_base_url_with_override(&state); - validate_download_url(&url, &relay_base)?; - - let bytes = fetch_blob_bytes(&url, &state).await?; - detect_and_validate_mime(&bytes)?; - Ok(tauri::ipc::Response::new(bytes)) -} - /// Copy an image from a relay media URL directly to the system clipboard. /// /// Fetches the image, decodes it to RGBA8, and writes it to the clipboard via @@ -255,7 +231,7 @@ pub async fn copy_text_to_clipboard( /// HTTP client, enforcing the download size cap. The caller is responsible for /// validating the URL origin and for any content-type checks on the result. async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result, String> { - fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES).await + fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES, None).await } /// The command-facing error for a media-fetch response status, or `None` if @@ -277,10 +253,11 @@ fn redirect_refusal_error(status: reqwest::StatusCode) -> Option { } /// Core streaming fetcher with a caller-supplied byte cap. -async fn fetch_blob_bytes_with_cap( +pub(super) async fn fetch_blob_bytes_with_cap( url: &str, state: &State<'_, AppState>, cap: u64, + cancellation: Option<&CancellationToken>, ) -> Result, String> { // Fetch bytes via the no-redirect media client (goes through the VPN tunnel). // A no-redirect client keeps the minted media auth token from being @@ -296,7 +273,16 @@ async fn fetch_blob_bytes_with_cap( req = req.header("authorization", auth); } - let resp = req.send().await.map_err(|e| classify_request_error(&e))?; + let request = req.send(); + let resp = if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), + result = request => result, + } + } else { + request.await + } + .map_err(|e| classify_request_error(&e))?; if let Some(err) = redirect_refusal_error(resp.status()) { return Err(err); @@ -321,7 +307,18 @@ async fn fetch_blob_bytes_with_cap( // even when Content-Length is missing or dishonest. let mut bytes = Vec::new(); let mut stream = resp.bytes_stream(); - while let Some(chunk) = stream.next().await { + loop { + let next = if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), + next = stream.next() => next, + } + } else { + stream.next().await + }; + let Some(chunk) = next else { + break; + }; let chunk = chunk.map_err(|e| classify_request_error(&e))?; if bytes.len() as u64 + chunk.len() as u64 > cap { return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); @@ -482,7 +479,7 @@ pub async fn fetch_snapshot_bytes( ensure_declared_size_within_cap(expected_size, kind)?; // ── Bounded fetch ───────────────────────────────────────────────────── - let bytes = fetch_blob_bytes_with_cap(&url, &state, cap).await?; + let bytes = fetch_blob_bytes_with_cap(&url, &state, cap, None).await?; // ── Post-fetch validation ───────────────────────────────────────────── // 1. Byte length must equal the declared imeta size. diff --git a/desktop/src-tauri/src/commands/media_fetch_cancellation.rs b/desktop/src-tauri/src/commands/media_fetch_cancellation.rs new file mode 100644 index 00000000000..6d29e0cd9d1 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_fetch_cancellation.rs @@ -0,0 +1,125 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tokio_util::sync::CancellationToken; + +use crate::app_state::AppState; +use crate::commands::media::detect_and_validate_mime; +use crate::commands::media_download::{ + fetch_blob_bytes_with_cap, validate_download_url, MAX_DOWNLOAD_BYTES, +}; +use crate::relay::relay_api_base_url_with_override; + +#[derive(Default)] +struct MediaFetchCancellations { + tokens: HashMap, +} + +impl MediaFetchCancellations { + fn begin(&mut self, request_id: &str) -> CancellationToken { + if let Some(cancel) = self.tokens.get(request_id).cloned() { + return cancel; + } + let cancel = CancellationToken::new(); + self.tokens.insert(request_id.to_string(), cancel.clone()); + cancel + } + + fn cancel(&mut self, request_id: &str) { + self.tokens + .entry(request_id.to_string()) + .or_default() + .cancel(); + } + + fn finish(&mut self, request_id: &str) { + self.tokens.remove(request_id); + } +} + +static MEDIA_FETCH_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(MediaFetchCancellations::default())); + +pub(super) fn begin_media_fetch(request_id: Option<&str>) -> Option { + let request_id = request_id?; + MEDIA_FETCH_CANCELLATIONS + .lock() + .ok() + .map(|mut fetches| fetches.begin(request_id)) +} + +pub(super) fn finish_media_fetch(request_id: Option<&str>) { + let Some(request_id) = request_id else { + return; + }; + if let Ok(mut fetches) = MEDIA_FETCH_CANCELLATIONS.lock() { + fetches.finish(request_id); + } +} + +/// Cancel a renderer-owned relay media fetch, including an in-flight body. +#[tauri::command] +pub fn cancel_media_fetch(request_id: String) { + if let Ok(mut fetches) = MEDIA_FETCH_CANCELLATIONS.lock() { + fetches.cancel(&request_id); + } +} + +/// Release renderer ownership after the fetch promise settles. +#[tauri::command] +pub fn release_media_fetch(request_id: String) { + finish_media_fetch(Some(&request_id)); +} + +/// Fetch relay media bytes with renderer-owned cancellation. +#[tauri::command] +pub async fn fetch_media_bytes( + url: String, + request_id: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let cancellation = begin_media_fetch(request_id.as_deref()); + let result = async { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + let bytes = + fetch_blob_bytes_with_cap(&url, &state, MAX_DOWNLOAD_BYTES, cancellation.as_ref()) + .await?; + detect_and_validate_mime(&bytes)?; + Ok(tauri::ipc::Response::new(bytes)) + } + .await; + finish_media_fetch(request_id.as_deref()); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let mut fetches = MediaFetchCancellations::default(); + fetches.cancel("cancel-before-begin"); + + let cancellation = fetches.begin("cancel-before-begin"); + + assert!(cancellation.is_cancelled()); + fetches.finish("cancel-before-begin"); + assert!(fetches.tokens.is_empty()); + } + + #[test] + fn cancellation_reaches_active_owner() { + let mut fetches = MediaFetchCancellations::default(); + let cancellation = fetches.begin("active-fetch"); + + fetches.cancel("active-fetch"); + + assert!(cancellation.is_cancelled()); + fetches.finish("active-fetch"); + assert!(fetches.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/media_filename.rs b/desktop/src-tauri/src/commands/media_filename.rs new file mode 100644 index 00000000000..0f6bb2bd736 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_filename.rs @@ -0,0 +1,29 @@ +/// Sanitize a filename for use as a display label in the imeta `filename` field. +/// +/// Strips directory components, removes control characters, and bounds length +/// to 255 so the resulting name always passes relay ingest validation. +pub(crate) fn sanitize_filename(name: &str) -> String { + let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); + let cleaned: String = base.chars().filter(|c| !c.is_control()).take(255).collect(); + if cleaned.is_empty() { + "file".to_string() + } else { + cleaned + } +} + +#[cfg(test)] +mod tests { + use super::sanitize_filename; + + #[test] + fn strips_paths_controls_and_empty_names() { + assert_eq!(sanitize_filename("report.pdf"), "report.pdf"); + assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); + assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt"); + assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx"); + assert_eq!(sanitize_filename(""), "file"); + assert_eq!(sanitize_filename("/"), "file"); + assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt"); + } +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 3fb7eda5f07..30f9269f533 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -271,6 +271,91 @@ fn transcode_to_mp4_with_cancellation( Ok(output) } +/// Package a voice-note audio file in the relay's existing canonical video +/// envelope. The tiny H.264 track satisfies the deployed video validator while +/// the AAC track remains the only user-facing content in the voice-note player. +/// +/// Returns the path to a temp MP4. Caller must clean up. +pub(super) fn transcode_voice_note_to_mp4_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, +) -> Result { + let ffmpeg = find_ffmpeg()?; + let output = std::env::temp_dir().join(format!("buzz-voice-note-{}.mp4", uuid::Uuid::new_v4())); + + let result = run_ffmpeg_with_cancellation( + ffmpeg_command(&ffmpeg) + .args([ + "-y", + "-nostdin", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "color=c=black:s=16x16:r=1", + ]) + .arg("-i") + .arg(source) + .args([ + "-map", + "0:v:0", + "-map", + "1:a:0", + "-shortest", + "-map_metadata", + "-1", + "-map_chapters", + "-1", + "-sn", + "-dn", + "-fflags", + "+bitexact", + "-flags:v", + "+bitexact", + "-flags:a", + "+bitexact", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-tune", + "stillimage", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "96k", + "-movflags", + "+faststart", + "-metadata", + "encoder=", + ]) + .arg(&output) + .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); + let stderr = String::from_utf8_lossy(&result.stderr); + let detail = stderr + .lines() + .rev() + .find(|line| !line.is_empty() && !line.starts_with(" ")) + .unwrap_or("unknown error"); + return Err(format!("Voice note conversion failed: {detail}")); + } + + Ok(output) +} + /// Transcode a HEIC/HEIF still image to JPEG via ffmpeg. /// /// The Tauri webview / Chromium cannot decode HEIC, so iPhone photos uploaded @@ -655,6 +740,67 @@ mod tests { } } + #[test] + fn test_voice_note_envelope_passes_relay_video_validation() { + if find_ffmpeg().is_err() { + eprintln!("skipping voice-note round-trip: ffmpeg not found"); + return; + } + + let source = + std::env::temp_dir().join(format!("buzz-voice-test-{}.wav", uuid::Uuid::new_v4())); + let sample_rate = 24_000u32; + let sample_bytes = sample_rate as usize * 2; + let mut wav = Vec::with_capacity(44 + sample_bytes); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + sample_bytes as u32).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + wav.extend_from_slice(&2u16.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&(sample_bytes as u32).to_le_bytes()); + wav.resize(44 + sample_bytes, 0); + std::fs::write(&source, wav).expect("write voice-note fixture"); + + let output = match transcode_voice_note_to_mp4_with_cancellation(&source, None) { + Ok(output) => output, + Err(error) => { + eprintln!("skipping voice-note round-trip: {error}"); + let _ = std::fs::remove_file(&source); + return; + } + }; + let relay_config = buzz_media_pkg::MediaConfig { + s3_endpoint: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + s3_bucket: String::new(), + s3_region: "us-east-1".to_string(), + s3_addressing_style: buzz_media_pkg::S3AddressingStyle::Path, + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: String::new(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }; + let metadata = buzz_media_pkg::validation::validate_video_file(&output, &relay_config) + .expect("relay rejected the canonical voice-note envelope"); + let _ = std::fs::remove_file(&source); + let _ = std::fs::remove_file(&output); + + assert!(metadata.has_audio); + assert_eq!((metadata.width, metadata.height), (16, 16)); + assert!(metadata.duration_secs > 0.0); + } + /// Round-trip transcode test, gated on ffmpeg being present so CI without /// ffmpeg doesn't fail. Generates a HEIC via ffmpeg, then transcodes it /// back to JPEG and asserts the output is a valid JPEG. diff --git a/desktop/src-tauri/src/commands/media_voice_note.rs b/desktop/src-tauri/src/commands/media_voice_note.rs new file mode 100644 index 00000000000..a71d0157ab0 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_voice_note.rs @@ -0,0 +1,85 @@ +use tokio_util::sync::CancellationToken; + +use super::media_transcode::transcode_voice_note_to_mp4_with_cancellation; + +const VOICE_NOTE_MAX_INPUT_BYTES: usize = 128 * 1024 * 1024; + +pub(super) fn is_voice_note_filename(filename: Option<&str>) -> bool { + filename.is_some_and(|name| { + let lower = name.to_ascii_lowercase(); + lower.starts_with("voice-note-") && lower.ends_with(".wav") + }) +} + +pub(super) fn voice_note_mp4_filename(filename: &str) -> String { + filename + .strip_suffix(".wav") + .or_else(|| filename.strip_suffix(".WAV")) + .map_or_else(|| format!("{filename}.mp4"), |stem| format!("{stem}.mp4")) +} + +pub(super) async fn prepare_voice_note_for_upload( + data: Vec, + cancellation: Option<&CancellationToken>, +) -> Result<(Vec, Option>), String> { + validate_voice_note_input_size(data.len())?; + let cancellation = cancellation.cloned(); + tokio::task::spawn_blocking(move || { + let detected = infer::get(&data) + .ok_or_else(|| "Voice note has an unrecognized audio format.".to_string())?; + if !detected.mime_type().starts_with("audio/") { + return Err("Voice note upload did not contain audio.".to_string()); + } + + let tmp_input = + std::env::temp_dir().join(format!("buzz-voice-input-{}", uuid::Uuid::new_v4())); + let result = (|| { + std::fs::write(&tmp_input, &data) + .map_err(|error| format!("failed to prepare voice note: {error}"))?; + let output = + transcode_voice_note_to_mp4_with_cancellation(&tmp_input, cancellation.as_ref())?; + let bytes = std::fs::read(&output) + .map_err(|error| format!("failed to read prepared voice note: {error}")); + let _ = std::fs::remove_file(&output); + bytes.map(|bytes| (bytes, None)) + })(); + let _ = std::fs::remove_file(&tmp_input); + result + }) + .await + .map_err(|error| format!("voice note task failed: {error}"))? +} + +fn validate_voice_note_input_size(size: usize) -> Result<(), String> { + if size > VOICE_NOTE_MAX_INPUT_BYTES { + return Err(format!( + "Voice note exceeds the maximum input size of {VOICE_NOTE_MAX_INPUT_BYTES} bytes." + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + is_voice_note_filename, validate_voice_note_input_size, voice_note_mp4_filename, + VOICE_NOTE_MAX_INPUT_BYTES, + }; + + #[test] + fn voice_note_filenames_are_scoped_and_rewritten_for_video_upload() { + assert!(is_voice_note_filename(Some("voice-note-123.wav"))); + assert!(!is_voice_note_filename(Some("meeting.wav"))); + assert!(!is_voice_note_filename(Some("voice-note-123.mp4"))); + assert_eq!( + voice_note_mp4_filename("voice-note-123.wav"), + "voice-note-123.mp4" + ); + } + + #[test] + fn voice_note_input_size_is_bounded_before_transcoding() { + assert!(validate_voice_note_input_size(VOICE_NOTE_MAX_INPUT_BYTES).is_ok()); + assert!(validate_voice_note_input_size(VOICE_NOTE_MAX_INPUT_BYTES + 1).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7cb2d8e3b83..c8184a01031 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ mod agent_providers; mod agent_settings; mod agent_update_rollback; mod agents; +mod bestie; mod canvas; mod channel_reconnect_repair; mod channel_templates; @@ -30,11 +31,14 @@ mod managed_agent_definition; pub(crate) mod media; mod media_animated; mod media_download; +mod media_fetch_cancellation; +mod media_filename; mod media_gif; mod media_raw; mod media_snapshot_png; mod media_transcode; mod media_upload_progress; +mod media_voice_note; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; #[cfg(feature = "mesh-llm")] @@ -81,6 +85,7 @@ pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; +pub use bestie::*; pub use canvas::*; pub use channel_reconnect_repair::*; pub use channel_templates::*; @@ -97,6 +102,7 @@ pub use legacy_storage::*; pub use link_preview::*; pub use media::*; pub use media_download::*; +pub use media_fetch_cancellation::*; pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2b..517e333b293 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -23,14 +23,10 @@ //! 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, + materialize_snapshot_description, memory_entries_from_listing, parse_memory_level, + resolve_from_lists, validate_snapshot_encode_size, }; use crate::{ app_state::AppState, @@ -47,6 +43,9 @@ use crate::{ save_global_agent_config, validate_global_config, }, }; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; /// The Buzz card frame template — Tyler's gold-honeycomb base. Generation /// input only: it never participates in the snapshot manifest, PNG chunk, @@ -553,7 +552,8 @@ pub async fn mint_agent_card( let definitions = load_agent_definitions(&app)?; let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; - + let mut record = record; + materialize_snapshot_description(&mut record, is_definition, &definitions); let global = load_global_agent_config(&app).unwrap_or_default(); let personas = load_personas(&app).unwrap_or_default(); let persona_env = record diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index 407ab449744..d05e0f2480b 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -1,5 +1,5 @@ //! 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`). +//! stays under the 1500-line gate (same layout as `snapshot/tests.rs`). use super::*; use std::collections::BTreeMap; diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index 91616b225cf..2f19d1256e1 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -13,7 +13,7 @@ use crate::{ util::now_iso, }; -use super::{pending, retain_persona_pending, trim_optional, trim_required}; +use super::{normalize_description, pending, retain_persona_pending, trim_optional, trim_required}; #[tauri::command] pub async fn create_persona( @@ -29,6 +29,7 @@ pub async fn create_persona( // exact string before the ACP harness executes it. let system_prompt = input.system_prompt.clone(); validate_agent_definition_text(&display_name, &system_prompt)?; + let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); @@ -58,6 +59,7 @@ pub async fn create_persona( id: Uuid::new_v4().to_string(), display_name, avatar_url, + description, system_prompt, runtime, model, diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 189d2676c49..6a10a1f9ee2 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -17,6 +17,7 @@ fn make_agent( runtime_pid: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: pubkey.to_string(), name: "Test Agent".to_string(), persona_id: persona_id.map(str::to_string), diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 2080630742d..fe9fcbe406a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -131,6 +131,7 @@ pub async fn reconcile_inbound_persona_event( cached_binary_path.as_deref(), None, None, + None, ) .await .map_err(|error| { @@ -452,7 +453,9 @@ fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), &persona.display_name, &persona.system_prompt, ) - .map_err(|error| format!("Inbound persona definition is unsafe: {error}")) + .map_err(|error| format!("Inbound persona definition is unsafe: {error}"))?; + crate::managed_agents::validate_agent_description_text(persona.description.as_deref()) + .map_err(|error| format!("Inbound persona definition is unsafe: {error}")) } fn validate_inbound_managed_agent_definition( @@ -685,6 +688,7 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi Some(local) => { local.display_name = inbound.display_name; local.avatar_url = inbound.avatar_url; + local.description = inbound.description; local.system_prompt = inbound.system_prompt; local.runtime = inbound.runtime; local.model = inbound.model; diff --git a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs index a5ca5cd9b5d..390e4850773 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs @@ -28,6 +28,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index ab932437553..e90df637314 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -10,6 +10,7 @@ const UUID: &str = "11111111-2222-3333-4444-555555555555"; // sadscan:disable sq /// IS its UUID id. Carries env_vars + source_team that must survive a patch. fn local_in_app() -> AgentDefinition { AgentDefinition { + description: None, id: UUID.to_string(), display_name: "Local".to_string(), avatar_url: None, @@ -38,6 +39,7 @@ fn local_in_app() -> AgentDefinition { /// slug = Some(d-tag), empty env_vars, source_team None. fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: d_tag.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/a.png".to_string()), @@ -161,6 +163,7 @@ const AGENT_PUBKEY: &str = "agentpubkeyhex00000000000000000000000000000000000000 /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: AGENT_PUBKEY.to_string(), name: "Local Agent".to_string(), persona_id: Some("persona-local".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 81371e72ed0..ac43a4719ab 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -26,6 +26,38 @@ fn trim_optional(value: Option) -> Option { }) } +/// Validate the raw authored bytes before applying storage normalization. +/// This ordering is security-relevant: prohibited edge characters must be +/// rejected, never made invisible by trimming. +fn normalize_description(value: Option) -> Result, String> { + crate::managed_agents::validate_agent_description_text(value.as_deref())?; + Ok(trim_optional(value)) +} + +#[cfg(test)] +mod description_normalization_tests { + use super::normalize_description; + + #[test] + fn trims_visible_whitespace_and_collapses_blank_to_none() { + assert_eq!( + normalize_description(Some(" A careful agent. ".to_string())).unwrap(), + Some("A careful agent.".to_string()) + ); + assert_eq!( + normalize_description(Some(" ".to_string())).unwrap(), + None + ); + } + + #[test] + fn rejects_prohibited_characters_at_the_edges_before_trimming() { + for value in ["\nA careful agent.", "A careful agent.\n", "\u{feff}Agent"] { + assert!(normalize_description(Some(value.to_string())).is_err()); + } + } +} + mod pending; pub(in crate::commands) use pending::retain_persona_pending; pub(in crate::commands) use pending::retain_persona_pending_at; diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 30e2ec266db..3e4fabbcf5b 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -181,6 +181,9 @@ pub(super) fn prepare_persona_publication_at( &scoped_persona.display_name, &scoped_persona.system_prompt, )?; + crate::managed_agents::validate_agent_description_text( + scoped_persona.description.as_deref(), + )?; } let event = build_persona_event(&scoped_persona)? .custom_created_at(monotonic_created_at( @@ -307,6 +310,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index fa492b338b5..331ec9d0d70 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -146,6 +146,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e63..17eb1825c9f 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -2,7 +2,7 @@ //! and their supporting helpers. //! //! Import-side commands and helpers live in `snapshot::import` to keep this -//! file under the 1000-line gate. +//! file under the 1500-line gate. //! //! Split from `personas/mod.rs` to keep that file under the line-count gate. @@ -56,6 +56,25 @@ pub(crate) fn resolve_from_lists<'a>( Err(format!("agent {id:?} not found")) } +/// Materialize persona-owned display metadata onto a cloned instance for +/// portable snapshot construction. Keyless definition records already carry +/// their own description. +pub(crate) fn materialize_snapshot_description( + record: &mut ManagedAgentRecord, + is_definition: bool, + definitions: &[ManagedAgentRecord], +) { + if is_definition { + return; + } + if let Some(persona_id) = record.persona_id.as_deref() { + record.description = definitions + .iter() + .find(|definition| definition.slug.as_deref() == Some(persona_id)) + .and_then(|definition| definition.description.clone()); + } +} + /// Validate that `memory_source_pubkey` is an appropriate source for a /// memory-bearing snapshot export. /// @@ -250,6 +269,7 @@ pub(crate) async fn materialize_snapshot_bytes( let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) .map(|(r, is_def)| (r.clone(), is_def))?; let mut def_record = def_record; + materialize_snapshot_description(&mut def_record, is_definition, &definitions); // A snapshot is a verbatim portable copy of the effective runtime, // provider, and model configuration, not a pointer to the sender's // machine-wide defaults. This does not translate or substitute values 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 ff2b4535294..55a64db59bc 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), slug: Some(slug.to_string()), name: slug.to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 729222d3831..041a0b91dc9 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -1,6 +1,6 @@ //! Import-side helpers for `buzz-agent-snapshot v1`. //! -//! Extracted from `snapshot.rs` to keep that file under the 1000-line gate. +//! Extracted from `snapshot.rs` to keep that file under the 1500-line gate. //! The Tauri commands here (`preview_agent_snapshot_import`, //! `confirm_agent_snapshot_import`) are re-exported from `snapshot.rs` and //! registered in `lib.rs` through the same `personas::` path as the export @@ -21,7 +21,7 @@ use crate::{ load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, - relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, + relay::{effective_agent_relay_url, relay_ws_url_with_override}, util::now_iso, }; @@ -557,12 +557,14 @@ pub async fn confirm_agent_snapshot_import( let now = now_iso(); let persona_id = uuid::Uuid::new_v4().to_string(); - // Build persona from snapshot definition. let persona = AgentDefinition { id: persona_id.clone(), display_name: display_name.clone(), avatar_url: effective_avatar.clone(), + description: crate::managed_agents::effective_agent_description( + snapshot.profile.about.as_deref(), + ), system_prompt: snapshot .definition .system_prompt @@ -592,13 +594,16 @@ pub async fn confirm_agent_snapshot_import( // Enqueue the kind:30175 persona event via the retention path. super::super::pending::retain_persona_pending(&app, &state, &persona); - // Build the managed agent record — no machine-local commands, no // secrets, no lineage from the snapshot. let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, + // Linked definitions remain the sole description authority. Do + // not persist a second instance copy that can go stale after an + // edit or survive a later definition deletion. + description: None, slug: None, persona_id: Some(persona_id.clone()), private_key_nsec: private_key_nsec.clone(), @@ -680,16 +685,16 @@ pub async fn confirm_agent_snapshot_import( // ── Phase 3b: publish kind:0 profile (async, outside lock) ─────────────── let relay_url = effective_agent_relay_url(&record.relay_url, &relay_ws_url_with_override(&state)); - let profile_sync_error = sync_managed_agent_profile( + let profile_sync_error = crate::commands::agents::publish_persona_profile( &state, - &relay_url, + &record.relay_url, &agent_keys, &display_name, effective_avatar.as_deref(), + &persona, auth_tag.as_deref(), ) - .await - .err(); + .await; // ── Phase 4: restore memory (async, outside lock) ───────────────────────── let memory_total = snapshot.memory.entries.len(); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 6292a4dd258..abf4bef443d 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; /// persona_id. fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), slug: Some(slug.to_string()), name: slug.to_string(), @@ -90,6 +91,17 @@ fn make_instance(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { } } +#[test] +fn linked_instance_snapshot_materializes_the_definition_description() { + let mut definition = make_definition("reviewer"); + definition.description = Some("Reviews changes.".to_string()); + let mut instance = make_instance("agent-pubkey", "reviewer"); + + materialize_snapshot_description(&mut instance, false, std::slice::from_ref(&definition)); + + assert_eq!(instance.description, definition.description); +} + /// Build a minimal valid AgentSnapshot for import tests. fn make_snapshot( memory_level: MemoryLevel, 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 index 36eaa997163..136ef65a453 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs @@ -1,7 +1,7 @@ //! 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, +//! 1500-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 diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs index 296444f78d0..43ca23cc822 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -1,7 +1,7 @@ //! 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, +//! 1500-line gate; `#[path]`-included from there as a child module, //! so `super::*` still resolves to the shared test helpers. use super::*; 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 index b17efa1ad11..e327cb0e491 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs @@ -1,6 +1,6 @@ //! 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 +//! `tests.rs` to keep that file under the 1500-line gate; `#[path]`-included //! from there as a child module, so `super::*` resolves to `tests`'s parent //! scope re-exports. diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index f9b09b4bbb4..46d0c8a99dc 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -14,7 +14,7 @@ use crate::{ util::now_iso, }; -use super::{pending, retain_persona_pending, trim_optional, trim_required}; +use super::{normalize_description, pending, retain_persona_pending, trim_optional, trim_required}; #[cfg(test)] mod name_propagation_tests; @@ -54,8 +54,72 @@ fn propagate_persona_name_rename( renamed } -/// Profile sync params collected under the store lock for async relay publish. -type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option, Option)>; +#[derive(Debug, PartialEq, Eq)] +struct LinkedProfileUpdate { + /// Whether this update changed bytes in the managed-agent record. + record_changed: bool, + /// Whether this instance needs a complete kind:0 replacement event. + profile_sync_required: bool, + /// Avatar to publish with the complete kind:0 replacement event. + profile_avatar: Option, +} + +/// Apply the persisted portion of a persona identity edit to one linked +/// instance and resolve the avatar for the complete kind:0 replacement. +/// +/// Description-only edits deliberately leave the record unchanged, but still +/// need a non-empty avatar projection for legacy records whose `avatar_url` +/// has not yet been backfilled. The persona avatar is authoritative there; +/// the effective command icon is the final fallback. +fn prepare_linked_profile_update( + record: &mut ManagedAgentRecord, + persona: &AgentDefinition, + renamed: bool, + avatar_changed: bool, + about_changed: bool, +) -> LinkedProfileUpdate { + let mut record_changed = renamed; + if avatar_changed { + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(persona), + record.agent_command_override.as_deref(), + ); + record.avatar_url = persona + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + record_changed = true; + } + + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(persona), + record.agent_command_override.as_deref(), + ); + let profile_avatar = record + .avatar_url + .clone() + .or_else(|| persona.avatar_url.clone()) + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + + LinkedProfileUpdate { + record_changed, + profile_sync_required: record_changed || about_changed, + profile_avatar, + } +} + +/// Profile sync params collected under the store lock for async relay publish: +/// (agent keys, relay url, display name, avatar url, kind:0 about, auth tag). +type ProfileSyncParams = Vec<( + nostr::Keys, + String, + String, + Option, + Option, + Option, +)>; #[tauri::command] pub async fn update_persona( @@ -96,6 +160,7 @@ pub(super) async fn update_persona_with( let display_name = trim_required(&input.display_name, "Display name")?; let system_prompt = input.system_prompt.clone(); validate_agent_definition_text(&display_name, &system_prompt)?; + let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); @@ -116,9 +181,17 @@ pub(super) async fn update_persona_with( let avatar_changed = persona.avatar_url != avatar_url; let name_changed = persona.display_name != display_name; let old_display_name = persona.display_name.clone(); + // The kind:0 `about` is the authored description, so a + // description edit changes what should be published. + let old_about = + crate::managed_agents::effective_agent_description(persona.description.as_deref()); + let new_about = + crate::managed_agents::effective_agent_description(description.as_deref()); + let about_changed = old_about != new_about; persona.display_name = display_name; persona.avatar_url = avatar_url; + persona.description = description; persona.system_prompt = system_prompt; persona.runtime = runtime; persona.model = model; @@ -142,9 +215,12 @@ pub(super) async fn update_persona_with( let retained = retain(&app, &state, &result)?; try_regenerate_nest(&app); - // If the avatar or display_name changed, propagate to linked agent - // records and collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed || name_changed { + // If the avatar, display_name, or effective description changed, + // propagate to linked agent records and collect relay profile sync + // params for the async phase. An about-only change touches no + // record bytes but still republishes each linked kind:0 profile. + let sync_params: ProfileSyncParams = if avatar_changed || name_changed || about_changed + { let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; @@ -169,28 +245,17 @@ pub(super) async fn update_persona_with( if record.persona_id.as_deref() != Some(&result.id) { continue; } - let mut record_changed = renamed.contains(&record.pubkey); - - if avatar_changed { - // Update the persisted avatar so reconciliation on next - // start agrees with what we're about to publish. - // When the persona avatar is cleared, fall back to the - // command-default icon so the record never stores `None` - // (which reconcile_agent_profile treats as "un-migrated"). - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - std::slice::from_ref(&result), - record.agent_command_override.as_deref(), - ); - record.avatar_url = result - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_cmd)); - record_changed = true; - } + let was_renamed = renamed.contains(&record.pubkey); + let update = prepare_linked_profile_update( + record, + &result, + was_renamed, + avatar_changed, + about_changed, + ); - if record_changed { - agents_modified = true; + agents_modified = agents_modified || update.record_changed; + if update.profile_sync_required { if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { let relay_url = crate::relay::effective_agent_relay_url( &record.relay_url, @@ -200,7 +265,8 @@ pub(super) async fn update_persona_with( agent_keys, relay_url, record.name.clone(), - record.avatar_url.clone(), + update.profile_avatar, + new_about.clone(), record.auth_tag.clone(), )); } @@ -231,19 +297,23 @@ pub(super) async fn update_persona_with( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; - // Phase 2: await relay profile sync for linked agents whose avatar or - // display_name was just updated. We await (rather than fire-and-forget) + // Phase 2: await relay profile sync for linked agents whose avatar, + // display_name, or effective description (kind:0 about) was just + // updated. We await (rather than fire-and-forget) // so the frontend cache invalidation that follows the mutation settlement // sees the fresh relay profile. Best-effort — failures are logged, not surfaced. if !profile_sync_params.is_empty() { let state = app.state::(); - for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params { + for (agent_keys, relay_url, display_name, avatar_url, about, auth_tag) in + profile_sync_params + { if let Err(e) = crate::relay::sync_managed_agent_profile( &state, &relay_url, &agent_keys, &display_name, avatar_url.as_deref(), + about.as_deref(), auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index edef958cef8..7aedcb25ef5 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -5,6 +5,7 @@ use super::*; fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: format!("pubkey-{name}"), name: name.to_string(), persona_id: Some(persona_id.to_string()), @@ -138,6 +139,52 @@ fn test_rename_only_affects_linked_persona() { ); } +#[test] +fn description_only_update_syncs_without_mutating_record_and_preserves_legacy_persona_avatar() { + let mut record = agent("persona-1", "Paul", Some("Paul")); + record.avatar_url = None; + record.slug = Some("persona-1".to_string()); + let before = record.clone(); + let mut persona = record + .clone() + .to_definition_view() + .expect("test record projects to a definition"); + persona.id = "persona-1".to_string(); + persona.avatar_url = Some("https://example.com/paul.png".to_string()); + + let update = prepare_linked_profile_update(&mut record, &persona, false, false, true); + + assert!(update.profile_sync_required, "about-only edits must sync"); + assert!( + !update.record_changed, + "about-only edits must not write the agent store" + ); + assert_eq!( + record, before, + "description-only edits leave instance bytes untouched" + ); + assert_eq!( + update.profile_avatar.as_deref(), + Some("https://example.com/paul.png"), + "complete kind:0 replacement must not clear a legacy agent avatar" + ); +} + +#[test] +fn unchanged_identity_needs_neither_store_write_nor_profile_sync() { + let mut record = agent("persona-1", "Paul", Some("Paul")); + record.slug = Some("persona-1".to_string()); + let persona = record + .clone() + .to_definition_view() + .expect("test record projects to a definition"); + + let update = prepare_linked_profile_update(&mut record, &persona, false, false, false); + + assert!(!update.record_changed); + assert!(!update.profile_sync_required); +} + #[test] fn test_rename_renames_all_matching_instances_in_one_pass() { // Several instances may carry the definition name (multi-instance deploys diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac5709..da93af673de 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -344,8 +344,8 @@ pub async fn get_presence( } // Presence is published as kind:20001 ephemeral events. Query the most - // recent per author. Some relays don't retain ephemeral events — we - // best-effort and return what we get. + // recent per author. Only a successful empty snapshot establishes absence; + // transport/auth/storage failures must reject so consumers remain unknown. let events = query_relay( &state, &[serde_json::json!({ @@ -353,8 +353,7 @@ pub async fn get_presence( "authors": pubkeys, })], ) - .await - .unwrap_or_default(); + .await?; let mut latest: HashMap = HashMap::new(); for ev in &events { @@ -482,3 +481,7 @@ mod tests { assert_eq!(filter["page"], serde_json::json!(1)); } } + +#[cfg(test)] +#[path = "profile_presence_tests.rs"] +mod presence_tests; diff --git a/desktop/src-tauri/src/commands/profile_presence_tests.rs b/desktop/src-tauri/src/commands/profile_presence_tests.rs new file mode 100644 index 00000000000..612e453273f --- /dev/null +++ b/desktop/src-tauri/src/commands/profile_presence_tests.rs @@ -0,0 +1,103 @@ +//! Drive the actual get_presence command through its authenticated HTTP query. +//! In particular, an error must not become a successful empty IPC snapshot. +use super::get_presence; +use crate::app_state::build_app_state; +use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; +use tauri::Manager; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::test] +async fn presence_command_preserves_query_failure_and_successful_absence() { + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + for (status, body) in [ + ("200 OK", "[]"), + ("401 Unauthorized", r#"{"error":"unauthorized"}"#), + ("429 Too Many Requests", r#"{"error":"retry in 1s"}"#), + ( + "500 Internal Server Error", + r#"{"error":"storage unavailable"}"#, + ), + ("200 OK", "not json"), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buf = [0; 4096]; + let count = stream.read(&mut buf).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&buf[..count]); + assert!(request.len() < 16384); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_lowercase(); + let length: usize = headers + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .map(|v| v.trim().parse().unwrap()) + }) + .unwrap(); + if request.len() >= end + 4 + length { + break; + } + } + } + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("POST /query ")); + assert!(request.to_lowercase().contains("authorization: nostr ")); + assert!(request.contains("20001")); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + let state = build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(format!("ws://{addr}")); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + get_presence(vec!["a".repeat(64)], app.state()), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); + if status == "200 OK" && body == "[]" { + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + serde_json::json!({}) + ); + } else { + assert!( + result.is_err(), + "{status} / {body} must reject, not return Offline: {result:?}" + ); + } + reset_rate_limit_gate(); + } +} + +#[tokio::test] +async fn presence_command_transport_failure_is_not_offline() { + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let state = build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(format!("ws://{addr}")); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let result = get_presence(vec!["a".repeat(64)], app.state()).await; + assert!(result.is_err(), "transport failure must reject: {result:?}"); + // Empty input does not require a relay and remains a genuine empty result. + assert!(get_presence(vec![], app.state()).await.unwrap().is_empty()); +} diff --git a/desktop/src-tauri/src/commands/project_repo_paths.rs b/desktop/src-tauri/src/commands/project_repo_paths.rs index 4193327c012..3fd4bbcaf82 100644 --- a/desktop/src-tauri/src/commands/project_repo_paths.rs +++ b/desktop/src-tauri/src/commands/project_repo_paths.rs @@ -145,13 +145,26 @@ pub(crate) fn find_local_repo_dir( } pub(crate) fn default_repos_root_candidates() -> Vec { + default_repos_root_candidates_for( + nest_dir(), + dirs::home_dir(), + crate::build_identity::is_demo_build(), + ) +} + +fn default_repos_root_candidates_for( + nest: Option, + home: Option, + is_demo_build: bool, +) -> Vec { let mut candidates = Vec::new(); - candidates.extend(nest_dir().map(|path| path.join("REPOS"))); - candidates.extend( - dirs::home_dir() - .map(|home| home.join(".buzz").join("REPOS")) - .filter(|path| !candidates.iter().any(|candidate| candidate == path)), - ); + candidates.extend(nest.map(|path| path.join("REPOS"))); + if !is_demo_build { + candidates.extend( + home.map(|home| home.join(".buzz").join("REPOS")) + .filter(|path| !candidates.iter().any(|candidate| candidate == path)), + ); + } candidates } @@ -190,3 +203,34 @@ pub(crate) fn canonical_repos_roots( } Ok(roots) } + +#[cfg(test)] +mod tests { + use super::default_repos_root_candidates_for; + use std::path::PathBuf; + + #[test] + fn production_keeps_the_legacy_repo_fallback() { + let home = PathBuf::from("/Users/example"); + assert_eq!( + default_repos_root_candidates_for( + Some(home.join(".buzz-dev")), + Some(home.clone()), + false, + ), + vec![home.join(".buzz-dev/REPOS"), home.join(".buzz/REPOS")] + ); + } + + #[test] + fn named_demos_only_search_their_selected_nest() { + let home = PathBuf::from("/Users/example"); + for slug in ["workstream-board", "second-demo"] { + let nest = home.join(format!(".buzz-demo-{slug}")); + assert_eq!( + default_repos_root_candidates_for(Some(nest.clone()), Some(home.clone()), true,), + vec![nest.join("REPOS")] + ); + } + } +} diff --git a/desktop/src-tauri/src/commands/qr_download.rs b/desktop/src-tauri/src/commands/qr_download.rs index 74a777b4528..5f5c399e783 100644 --- a/desktop/src-tauri/src/commands/qr_download.rs +++ b/desktop/src-tauri/src/commands/qr_download.rs @@ -1,7 +1,7 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use crate::commands::export_util::save_bytes_with_dialog; -use crate::commands::media::sanitize_filename; +use crate::commands::media_filename::sanitize_filename; use crate::commands::personas::PNG_MAGIC; fn decode_png_data_url(data_url: &str) -> Result, String> { diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 26f6450c568..9c57ce12b53 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -122,6 +122,9 @@ fn definition_from_snapshot( id: Uuid::new_v4().to_string(), display_name: member.profile.display_name.trim().to_string(), avatar_url: effective_avatar(member), + description: crate::managed_agents::effective_agent_description( + member.profile.about.as_deref(), + ), system_prompt: member.definition.system_prompt.clone().unwrap_or_default(), runtime: member.definition.runtime.clone(), model: member.definition.model.clone(), @@ -559,6 +562,10 @@ pub async fn confirm_team_snapshot_import( pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, + // Linked definitions remain the sole description authority. Do + // not persist a second instance copy that can go stale after an + // edit or survive a later definition deletion. + description: None, slug: None, persona_id: Some(definition.id.clone()), private_key_nsec: private_key_nsec.clone(), @@ -771,12 +778,15 @@ pub async fn confirm_team_snapshot_import( let relay_url = effective_agent_relay_url(&m.record.relay_url, &relay_ws); // Phase 4: profile sync (best-effort). + let profile_about = + crate::managed_agents::effective_agent_description(m.definition.description.as_deref()); let profile_sync_error = sync_managed_agent_profile( &state, &relay_url, &m.agent_keys, &m.display_name, m.effective_avatar.as_deref(), + profile_about.as_deref(), m.auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index b1c93a283ec..13c7f6ae810 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -55,6 +55,7 @@ fn snapshot(members: Vec) -> TeamSnapshot { fn team_export_round_trip_preserves_team_and_excludes_member_memory() { let definitions = vec![ AgentDefinition { + description: Some("A careful reviewer.".to_string()), id: "alice".to_string(), display_name: "Alice".to_string(), avatar_url: None, @@ -78,6 +79,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { updated_at: "now".to_string(), }, AgentDefinition { + description: None, id: "bob".to_string(), display_name: "Bob".to_string(), avatar_url: None, @@ -136,6 +138,11 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { assert_eq!(decoded.team.description.as_deref(), Some("Reviews changes")); assert_eq!(decoded.team.instructions.as_deref(), Some("Be thorough.")); assert_eq!(decoded.members.len(), 2); + assert_eq!( + decoded.members[0].profile.about.as_deref(), + Some("A careful reviewer.") + ); + assert_eq!(decoded.members[1].profile.about, None); assert!(decoded.members.iter().all(|member| { member.memory.level == MemoryLevel::None && member.memory.entries.is_empty() })); @@ -144,6 +151,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { #[test] fn team_export_with_instance_and_memory_level_uses_supplied_entries() { let definitions = vec![AgentDefinition { + description: None, id: "alice".to_string(), display_name: "Alice".to_string(), avatar_url: None, @@ -185,6 +193,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { // Build a fake instance record tied to this team+persona. let instance = ManagedAgentRecord { + description: None, pubkey: "a".repeat(64), name: "Alice".to_string(), display_name: None, @@ -298,6 +307,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { #[test] fn team_import_definitions_are_built_for_all_members() { let mut memory_bearing = member("Alice"); + memory_bearing.profile.about = Some(" A careful reviewer. ".to_string()); memory_bearing.memory = AgentSnapshotMemory { level: MemoryLevel::Everything, entries: vec![AgentSnapshotMemoryEntry { @@ -337,6 +347,11 @@ fn team_import_definitions_are_built_for_all_members() { && definition.respond_to_allowlist.is_empty() })); assert_eq!(definitions[0].system_prompt, "Alice prompt"); + assert_eq!( + definitions[0].description.as_deref(), + Some("A careful reviewer.") + ); + assert_eq!(definitions[1].description, None); } #[test] diff --git a/desktop/src-tauri/src/commands/teams/adopt/apply.rs b/desktop/src-tauri/src/commands/teams/adopt/apply.rs index f3e0bc708a4..d52e71aeee1 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/apply.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/apply.rs @@ -437,6 +437,9 @@ fn member_copy( Ok(AgentDefinition { id: Uuid::new_v4().to_string(), display_name: member.display_name.clone(), + // Team catalog members carry no public description; an adopted copy + // starts without one. + description: None, avatar_url: member.avatar_url.clone(), system_prompt: member.system_prompt.clone().unwrap_or_default(), runtime: member.runtime.clone(), diff --git a/desktop/src-tauri/src/commands/teams/adopt/tests.rs b/desktop/src-tauri/src/commands/teams/adopt/tests.rs index bd30cdacc24..2235bd0b2b9 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/tests.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/tests.rs @@ -23,6 +23,7 @@ fn persona(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: prompt.to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/teams/pending/tests.rs b/desktop/src-tauri/src/commands/teams/pending/tests.rs index 941f725c50b..7f4d31a6535 100644 --- a/desktop/src-tauri/src/commands/teams/pending/tests.rs +++ b/desktop/src-tauri/src/commands/teams/pending/tests.rs @@ -14,6 +14,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/teams/sharing/tests.rs b/desktop/src-tauri/src/commands/teams/sharing/tests.rs index 71f841d5803..a6e5a7d2d77 100644 --- a/desktop/src-tauri/src/commands/teams/sharing/tests.rs +++ b/desktop/src-tauri/src/commands/teams/sharing/tests.rs @@ -16,6 +16,7 @@ fn member(id: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: "One".to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 77d519b94ba..418f994fb3e 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -155,6 +155,7 @@ pub async fn apply_workspace( nsec: Option, repos_dir: Option, agent_managed_profiles: Option, + thread_scoped_acp_sessions: Option, app: AppHandle, ) -> Result<(), String> { let state = app.state::(); @@ -228,8 +229,15 @@ pub async fn apply_workspace( // experiment before launch-time restore can spawn any agents. Missing // means the stable behavior: desktop remains authoritative. state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .store(!agent_managed_profiles.unwrap_or(false), Ordering::Release); + // Persisted frontend experiment state must land before launch-time + // restore so every restored agent starts with the selected ACP policy. + // Missing preserves the stable channel-scoped behavior. + state.thread_scoped_acp_sessions_enabled().store( + thread_scoped_acp_sessions.unwrap_or(false), + Ordering::Release, + ); // ── Filesystem side-effect (non-fatal) ──────────────────────────────── // Persist the *effective* repos_dir (None when the candidate failed diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 83ac7e59ff9..614c62e1aaf 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -404,6 +404,18 @@ const ENTITY_LINK_TABS: [&str; 6] = [ "channels", ]; +/// Validate the build-specific transport URL, then hand the frontend its +/// canonical entity-link representation. Never broaden frontend scheme trust. +fn canonical_entity_deep_link(url: &Url, build_scheme: &str) -> Option { + if url.scheme() != build_scheme { + return None; + } + parse_entity_deep_link(url)?; + let mut canonical = url.clone(); + canonical.set_scheme("buzz").ok()?; + Some(canonical.into()) +} + /// The canonical-form rules match `parseEntityLink`: no path segments, no /// fragment, and no parameters beyond `owner`/`d` (plus `id` for event /// links and the optional `tab` for coordinate links), so a future @@ -600,7 +612,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } }; - if url.scheme() != "buzz" { + if url.scheme() != crate::build_identity::deep_link_scheme() { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } @@ -678,17 +690,17 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let _ = app.emit("deep-link-message", payload); } Some("repo" | "project" | "pr" | "issue") => { - // `buzz://repo|project?owner=&d=` and - // `buzz://pr|issue?id=&owner=&d=` — the - // share links copied from the Projects UI. The frontend owns - // routing (`useEntityDeepLinks`), so the validated URL is - // forwarded unchanged. - if parse_entity_deep_link(&url).is_none() { + // OS routing uses this build's scheme; frontend navigation consumes + // canonical buzz:// entity links rather than transport identity. + let Some(href) = canonical_entity_deep_link( + &url, + crate::build_identity::deep_link_scheme().as_ref(), + ) else { eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); return; - } + }; activate_main_window(app); - let pending = queue_entity_deep_link(app, url_str.to_owned()); + let pending = queue_entity_deep_link(app, href); let _ = app.emit("deep-link-entity", pending); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs index 84a08c4c64e..da960f3a2d9 100644 --- a/desktop/src-tauri/src/deep_link_tests.rs +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -1,10 +1,11 @@ use url::Url; use super::{ - parse_add_community_deep_link, parse_channel_deep_link, parse_entity_deep_link, - parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, - PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingEntityDeepLinks, - PendingNavigationDeepLink, PendingNavigationDeepLinks, ENTITY_LINK_TABS, + canonical_entity_deep_link, parse_add_community_deep_link, parse_channel_deep_link, + parse_entity_deep_link, parse_join_deep_link, parse_message_deep_link, + parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + PendingEntityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks, + ENTITY_LINK_TABS, }; fn entity_link_golden() -> serde_json::Value { @@ -12,6 +13,35 @@ fn entity_link_golden() -> serde_json::Value { .expect("valid entity-links golden fixture") } +#[test] +fn demo_entity_transport_produces_the_frontend_golden_contract() { + let golden = entity_link_golden(); + let scheme = "buzz-demo-board-1234567812345678"; + for canonical in golden["links"].as_object().unwrap().values() { + let canonical = canonical.as_str().unwrap(); + let transport = Url::parse(&canonical.replacen("buzz:", &format!("{scheme}:"), 1)).unwrap(); + let href = canonical_entity_deep_link(&transport, scheme).unwrap(); + // This same fixture is parsed and routed by the frontend entity tests. + assert_eq!(href, canonical); + let queue = PendingEntityDeepLinks::default(); + let pending = queue.enqueue(href); + assert_eq!(queue.first().unwrap().href, canonical); + assert!(queue.acknowledge(&pending.id)); + assert!(queue.first().is_none()); + assert!(canonical_entity_deep_link(&transport, "buzz").is_none()); + assert!( + canonical_entity_deep_link(&transport, "buzz-demo-other-8765432187654321").is_none() + ); + assert!(canonical_entity_deep_link(&Url::parse(canonical).unwrap(), scheme).is_none()); + assert_eq!( + canonical_entity_deep_link(&Url::parse(canonical).unwrap(), "buzz").as_deref(), + Some(canonical) + ); + } + let invalid = Url::parse(&format!("{scheme}://repo?owner=bad&d=repo")).unwrap(); + assert!(canonical_entity_deep_link(&invalid, scheme).is_none()); +} + #[test] fn parse_entity_deep_link_accepts_every_share_link_shape() { let golden = entity_link_golden(); diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 0e718079a30..29e74cfb506 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -109,6 +109,7 @@ async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() { &format!("agent {NCRYPTSEC}"), None, None, + None, ) .await .unwrap_err(); diff --git a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs index 8d370285739..5fcf66a4588 100644 --- a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs +++ b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs @@ -14,6 +14,7 @@ fn member(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: prompt.to_string(), runtime: None, diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index f9f70657698..09154d5237d 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -587,6 +587,10 @@ fn tts_model_slot() -> ModelSlot { .with_expected_sizes(tts_expected_size) } +fn models_dir(nest_dir: PathBuf) -> PathBuf { + nest_dir.join("models") +} + // ── ModelManager ────────────────────────────────────────────────────────────── /// Manages download and location of STT/TTS model files. @@ -594,18 +598,18 @@ fn tts_model_slot() -> ModelSlot { /// Cheap to clone — all inner state is behind `Arc`. #[derive(Clone)] pub struct ModelManager { - /// `~/.buzz/models/` + /// Model storage under the selected build's nest. models_dir: PathBuf, stt: ModelSlot, tts: ModelSlot, } impl ModelManager { - /// Create a new `ModelManager` rooted at `~/.buzz/models/`. + /// Create a new `ModelManager` rooted in the selected build's nest. /// - /// Returns `None` if the home directory cannot be resolved. + /// Returns `None` if the nest directory cannot be resolved. pub fn new() -> Option { - let models_dir = dirs::home_dir()?.join(".buzz").join("models"); + let models_dir = models_dir(crate::managed_agents::nest_dir()?); let manager = Self { models_dir, stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION), diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs index 699ffbe459f..5f70b1f3f3a 100644 --- a/desktop/src-tauri/src/huddle/models_tests.rs +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn voice_models_follow_the_selected_build_nest() { + let home = PathBuf::from("/Users/example"); + for nest_name in [ + ".buzz", + ".buzz-demo-workstream-board", + ".buzz-demo-second-demo", + ] { + let nest = home.join(nest_name); + assert_eq!(models_dir(nest.clone()), nest.join("models")); + } +} + fn create_ready_model_dir(root: &Path) -> PathBuf { let model_dir = root.join(TTS_MODEL_DIR_NAME); std::fs::create_dir_all(&model_dir).expect("create model dir"); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2dde312d779..12082a2a82e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod app_menu; mod app_state; mod archive; +mod build_identity; mod builderlab; mod channel_head_cache; mod commands; @@ -127,7 +128,7 @@ pub fn run() { } // Forward any deep link URLs from the duplicate launch. for arg in &argv { - if arg.starts_with("buzz://") { + if crate::build_identity::is_deep_link_for_build(arg) { handle_deep_link_url(app, arg); } } @@ -397,7 +398,10 @@ pub fn run() { // the now-inert ~/.sprout; the frontend dedupes the toast. // Suppressed when a reset completed this boot: the nest was wiped and // a fresh ~/.sprout-less state is exactly what we want. - if !reset_outcome.completed && migration::migrate_legacy_nest() { + if !crate::build_identity::is_demo_build() + && !reset_outcome.completed + && migration::migrate_legacy_nest() + { let _ = app_handle.emit("legacy-nest-migrated", ()); } @@ -617,6 +621,10 @@ pub fn run() { create_channel, ensure_starter_channels, open_dm, + get_bestie_assignment, + assign_bestie, + clear_bestie_assignment, + resolve_bestie_conversation, hide_dm, get_channel_details, get_channel_members, @@ -668,6 +676,8 @@ pub fn run() { save_png_data_url, download_file, fetch_media_bytes, + cancel_media_fetch, + release_media_fetch, copy_image_to_clipboard, copy_text_to_clipboard, read_clipboard_text, @@ -696,6 +706,7 @@ pub fn run() { start_managed_agent, stop_managed_agent, set_agent_managed_profiles, + set_thread_scoped_acp_sessions, set_managed_agent_start_on_app_launch, set_managed_agent_auto_restart, delete_managed_agent, @@ -708,7 +719,6 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, - persist_agent_effort_level, get_global_agent_config, set_global_agent_config, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/agent_description.rs b/desktop/src-tauri/src/managed_agents/agent_description.rs new file mode 100644 index 00000000000..af0a406404e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_description.rs @@ -0,0 +1,154 @@ +//! Effective public agent description — the Rust twin of +//! `desktop/src/features/agents/lib/agentDescription.ts`. +//! +//! The desktop publishes an agent's effective description as the `about` +//! field of its kind:0 profile event. Only the owner-authored +//! `AgentDefinition.description` publishes; a blank description publishes an +//! empty `about`, exactly as before the field existed. + +use super::{AgentDefinition, ManagedAgentRecord}; + +/// The description to publish for an agent: the authored `description`, +/// trimmed, when non-empty; otherwise `None`. +/// +/// TS twin: `effectiveAgentDescription` in `lib/agentDescription.ts`. +pub(crate) fn effective_agent_description(description: Option<&str>) -> Option { + let authored = description.map(str::trim).unwrap_or(""); + if authored.is_empty() { + return None; + } + Some(authored.to_string()) +} + +/// Effective description for a managed-agent record's kind:0 profile. +/// +/// A persona-linked instance publishes its linked definition's authored +/// description — the definition is the authority for identity metadata, +/// matching how the card face resolves it. A missing linked definition yields +/// no description rather than reviving a stale instance copy. Only a +/// definition-less instance falls back to its own record field. +pub(crate) fn record_effective_description( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> Option { + if let Some(persona_id) = record.persona_id.as_deref() { + return personas + .iter() + .find(|persona| persona.id == persona_id) + .and_then(|persona| effective_agent_description(persona.description.as_deref())); + } + effective_agent_description(record.description.as_deref()) +} + +// Tests mirror `lib/agentDescription.test.mjs` case-for-case so the Rust +// publish path and the TS display path cannot drift silently. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authored_description_wins() { + assert_eq!( + effective_agent_description(Some("Reviews desktop PRs.")).as_deref(), + Some("Reviews desktop PRs.") + ); + } + + #[test] + fn authored_description_is_trimmed() { + assert_eq!( + effective_agent_description(Some(" Reviews desktop PRs. ")).as_deref(), + Some("Reviews desktop PRs.") + ); + } + + #[test] + fn blank_and_none_descriptions_yield_none() { + assert_eq!(effective_agent_description(None), None); + assert_eq!(effective_agent_description(Some("")), None); + assert_eq!(effective_agent_description(Some(" ")), None); + } + + fn record_with(description: Option<&str>, persona_id: Option<&str>) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_str( + 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, + "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"); + record.description = description.map(str::to_string); + record.persona_id = persona_id.map(str::to_string); + record + } + + fn persona_with(id: &str, description: Option<&str>) -> AgentDefinition { + let mut persona: AgentDefinition = serde_json::from_str( + r#"{ + "id": "placeholder", + "display_name": "Helper", + "system_prompt": "You help.", + "is_builtin": false, + "is_active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z" + }"#, + ) + .expect("sample persona"); + persona.id = id.to_string(); + persona.description = description.map(str::to_string); + persona + } + + #[test] + fn linked_record_publishes_the_definition_description() { + let record = record_with(Some("record-level"), Some("p1")); + let personas = vec![persona_with("p1", Some("Definition description."))]; + assert_eq!( + record_effective_description(&record, &personas).as_deref(), + Some("Definition description.") + ); + } + + #[test] + fn linked_record_with_blank_definition_description_publishes_none() { + let record = record_with(Some("record-level"), Some("p1")); + let personas = vec![persona_with("p1", None)]; + assert_eq!(record_effective_description(&record, &personas), None); + } + + #[test] + fn definition_less_record_falls_back_to_its_own_description() { + let record = record_with(Some("Record description."), None); + assert_eq!( + record_effective_description(&record, &[]).as_deref(), + Some("Record description.") + ); + } + + #[test] + fn dangling_persona_link_does_not_revive_a_stale_record_description() { + let record = record_with(Some("Stale imported description."), Some("missing")); + assert_eq!(record_effective_description(&record, &[]), None); + } + + #[test] + fn no_description_anywhere_yields_none() { + let record = record_with(None, None); + assert_eq!(record_effective_description(&record, &[]), None); + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index ce30dcae851..85f34260ce7 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -164,6 +164,7 @@ mod tests { fn sample_agent() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agentpubkeyhex".to_string(), name: "Test Agent".to_string(), persona_id: Some("persona-1".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 4b734ce1591..abe48e49fa8 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -226,7 +226,7 @@ pub fn build_snapshot( .display_name .clone() .unwrap_or_else(|| record.name.clone()), - about: None, // kind:0 `about` not yet surfaced in ManagedAgentRecord + about: super::effective_agent_description(record.description.as_deref()), avatar_data_url, avatar_url: avatar_url_ref, }; @@ -419,6 +419,8 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> .unwrap_or_default(), ) .map_err(|error| format!("Snapshot definition is unsafe: {error}"))?; + super::validate_agent_description_text(snapshot.profile.about.as_deref()) + .map_err(|error| format!("Snapshot description is unsafe: {error}"))?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8fd631b5b5b..131966409b0 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -366,6 +366,7 @@ mod tests { /// pubkey/nsec pair matters here. fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey, name: "Locked Test".to_string(), persona_id: 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 index 02b4151da3f..da881f64f5a 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -1,7 +1,7 @@ //! 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. +//! 1500-line gate; `#[path]`-included from there. use super::*; use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; @@ -11,6 +11,7 @@ use std::collections::BTreeMap; /// relevant to snapshot export are filled; the rest use defaults. fn minimal_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "deadbeef".to_string(), name: "Test Agent".to_string(), display_name: Some("Test Agent Display".to_string()), @@ -598,9 +599,14 @@ fn definition_fields_present_in_snapshot() { #[test] fn profile_fields_present_in_snapshot() { - let record = minimal_record(); + let mut record = minimal_record(); + record.description = Some(" A careful test agent. ".to_string()); let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + assert_eq!( + snapshot.profile.about.as_deref(), + Some("A careful test agent.") + ); // No bytes → should fall back to avatar_url assert_eq!( snapshot.profile.avatar_url.as_deref(), @@ -609,6 +615,16 @@ fn profile_fields_present_in_snapshot() { assert!(snapshot.profile.avatar_data_url.is_none()); } +#[test] +fn snapshot_rejects_unsafe_or_overlong_description() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.profile.about = Some("unsafe\u{200b}description".to_string()); + assert!(validate_snapshot(&snapshot).is_err()); + + snapshot.profile.about = Some("a".repeat(281)); + assert!(validate_snapshot(&snapshot).is_err()); +} + #[test] fn avatar_inlined_when_under_size_limit() { let record = minimal_record(); diff --git a/desktop/src-tauri/src/managed_agents/bestie_assignment.rs b/desktop/src-tauri/src/managed_agents/bestie_assignment.rs new file mode 100644 index 00000000000..e823db124b8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/bestie_assignment.rs @@ -0,0 +1,595 @@ +//! Durable, owner-and-relay-scoped Bestie designation storage. + +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +use super::{retention::open_retention_db, storage::atomic_write_json_restricted}; + +const RECOVERY_JOURNAL_FILE: &str = "bestie-assignment-recovery.json"; + +/// The one durable Bestie designation in a retention scope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BestieAssignment { + pub agent_pubkey: String, +} + +fn ensure_table(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS bestie_assignments ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + agent_pubkey TEXT NOT NULL + );", + ) + .map_err(|error| format!("failed to create bestie assignment table: {error}")) +} + +/// Read the designation for the already-scoped retention database. +pub fn get_assignment(conn: &Connection) -> Result, String> { + ensure_table(conn)?; + conn.query_row( + "SELECT agent_pubkey FROM bestie_assignments WHERE singleton = 1", + [], + |row| { + Ok(BestieAssignment { + agent_pubkey: row.get(0)?, + }) + }, + ) + .optional() + .map_err(|error| format!("failed to read bestie assignment: {error}")) +} + +/// Atomically create or replace the one designation in this scope. +pub fn replace_assignment( + conn: &mut Connection, + agent_pubkey: &str, +) -> Result { + ensure_table(conn)?; + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + let transaction = conn + .transaction() + .map_err(|error| format!("failed to begin bestie assignment transaction: {error}"))?; + transaction + .execute( + "INSERT INTO bestie_assignments (singleton, agent_pubkey) + VALUES (1, ?1) + ON CONFLICT(singleton) DO UPDATE SET agent_pubkey = excluded.agent_pubkey", + params![normalized], + ) + .map_err(|error| format!("failed to replace bestie assignment: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit bestie assignment: {error}"))?; + get_assignment(conn)?.ok_or_else(|| "bestie assignment was not persisted".to_string()) +} + +/// Clear the designation without changing or stopping the agent. +pub fn clear_assignment(conn: &mut Connection) -> Result<(), String> { + ensure_table(conn)?; + let transaction = conn + .transaction() + .map_err(|error| format!("failed to begin bestie clear transaction: {error}"))?; + transaction + .execute("DELETE FROM bestie_assignments WHERE singleton = 1", []) + .map_err(|error| format!("failed to clear bestie assignment: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit bestie clear: {error}")) +} + +/// Whether the same agent is still designated after an asynchronous operation. +pub fn assignment_matches(conn: &Connection, agent_pubkey: &str) -> Result { + ensure_table(conn)?; + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + Ok(get_assignment(conn)?.is_some_and(|assignment| assignment.agent_pubkey == normalized)) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ScopedAssignment { + agent_pubkey: String, + path: PathBuf, +} + +#[derive(Debug, Deserialize, Serialize)] +struct AssignmentRecoveryJournal { + assignments: Vec, + version: u8, +} + +fn recovery_journal_path(base_dir: &Path) -> PathBuf { + base_dir.join(RECOVERY_JOURNAL_FILE) +} + +fn persist_recovery_journal( + base_dir: &Path, + assignments: &[ScopedAssignment], +) -> Result<(), String> { + fs::create_dir_all(base_dir) + .map_err(|error| format!("failed to create agents directory: {error}"))?; + let payload = serde_json::to_vec_pretty(&AssignmentRecoveryJournal { + assignments: assignments.to_vec(), + version: 1, + }) + .map_err(|error| format!("failed to serialize Bestie recovery journal: {error}"))?; + atomic_write_json_restricted(&recovery_journal_path(base_dir), &payload) + .map_err(|error| format!("failed to persist Bestie recovery journal: {error}")) +} + +fn load_recovery_journal(base_dir: &Path) -> Result, String> { + let path = recovery_journal_path(base_dir); + let payload = match fs::read(&path) { + Ok(payload) => payload, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read Bestie recovery journal {}: {error}", + path.display() + )) + } + }; + let journal: AssignmentRecoveryJournal = serde_json::from_slice(&payload) + .map_err(|error| format!("failed to parse Bestie recovery journal: {error}"))?; + if journal.version != 1 { + return Err(format!( + "unsupported Bestie recovery journal version {}", + journal.version + )); + } + let retention_dir = base_dir.join("retention"); + for assignment in &journal.assignments { + if assignment.path.parent() != Some(retention_dir.as_path()) + || assignment.path.extension().and_then(|value| value.to_str()) != Some("db") + { + return Err(format!( + "Bestie recovery journal contains an invalid retention path: {}", + assignment.path.display() + )); + } + } + Ok(Some(journal)) +} + +fn remove_recovery_journal(base_dir: &Path) -> Result<(), String> { + let path = recovery_journal_path(base_dir); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove Bestie recovery journal {}: {error}", + path.display() + )), + } +} + +fn retention_db_paths(base_dir: &Path) -> Result, String> { + let retention_dir = base_dir.join("retention"); + let entries = match fs::read_dir(&retention_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "failed to read retention directory {}: {error}", + retention_dir.display() + )) + } + }; + + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "failed to inspect retention directory {}: {error}", + retention_dir.display() + ) + })?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("db") { + continue; + } + paths.push(path); + } + paths.sort(); + Ok(paths) +} + +fn matching_assignments( + base_dir: &Path, + agent_pubkey: &str, +) -> Result, String> { + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + let mut assignments = Vec::new(); + // Read and validate every scope before mutating any of them. A broken later + // database therefore cannot leave an already-cleared prefix behind. + for path in retention_db_paths(base_dir)? { + let conn = open_retention_db(&path)?; + ensure_table(&conn)?; + if assignment_matches(&conn, &normalized)? { + assignments.push(ScopedAssignment { + agent_pubkey: normalized.clone(), + path, + }); + } + } + Ok(assignments) +} + +fn clear_scope(assignment: &ScopedAssignment) -> Result<(), String> { + let conn = open_retention_db(&assignment.path)?; + conn.execute( + "DELETE FROM bestie_assignments WHERE singleton = 1 AND agent_pubkey = ?1", + params![assignment.agent_pubkey], + ) + .map_err(|error| { + format!( + "failed to clear bestie assignment in {}: {error}", + assignment.path.display() + ) + })?; + Ok(()) +} + +fn apply_to_assignments( + assignments: &[ScopedAssignment], + mut apply: impl FnMut(&ScopedAssignment) -> Result<(), String>, + action: &str, +) -> Result<(), String> { + let mut failures = Vec::new(); + for assignment in assignments { + if let Err(error) = apply(assignment) { + failures.push(format!("{}: {error}", assignment.path.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(format!( + "failed to {action} Bestie assignments: {}", + failures.join("; ") + )) + } +} + +fn restore_scope(assignment: &ScopedAssignment) -> Result<(), String> { + let mut conn = open_retention_db(&assignment.path)?; + replace_assignment(&mut conn, &assignment.agent_pubkey).map(|_| ()) +} + +fn restore_assignments(assignments: &[ScopedAssignment]) -> Result<(), String> { + apply_to_assignments(assignments, restore_scope, "restore") +} + +/// Replay a durable interrupted-deletion journal. +/// +/// The managed-agent store is authoritative for which side of the operation +/// committed: a retained agent gets its exact pre-delete assignments restored; +/// an absent agent gets those exact assignments cleared. The journal is only +/// removed after every scope reaches that deterministic state. +pub fn recover_pending_assignment_cleanup( + base_dir: &Path, + agent_exists: impl FnOnce(&str) -> bool, +) -> Result<(), String> { + let Some(journal) = load_recovery_journal(base_dir)? else { + return Ok(()); + }; + let agent_pubkey = journal + .assignments + .first() + .map(|assignment| assignment.agent_pubkey.as_str()) + .ok_or_else(|| "Bestie recovery journal contains no assignments".to_string())?; + if journal + .assignments + .iter() + .any(|assignment| assignment.agent_pubkey != agent_pubkey) + { + return Err("Bestie recovery journal contains multiple agents".to_string()); + } + if agent_exists(agent_pubkey) { + restore_assignments(&journal.assignments)?; + } else { + apply_to_assignments(&journal.assignments, clear_scope, "clear")?; + } + remove_recovery_journal(base_dir) +} + +fn clear_scoped_assignments( + assignments: &[ScopedAssignment], + mut clear: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result<(), String> { + for assignment in assignments { + clear(assignment)?; + } + Ok(()) +} + +fn rollback_with_journal( + base_dir: &Path, + assignments: &[ScopedAssignment], + error: String, + restore: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result { + match apply_to_assignments(assignments, restore, "restore") { + Ok(()) => match remove_recovery_journal(base_dir) { + Ok(()) => Err(error), + Err(journal_error) => Err(format!("{error}; {journal_error}")), + }, + Err(restore_error) => Err(format!("{error}; {restore_error}")), + } +} + +fn with_agent_assignments_cleared_using( + base_dir: &Path, + agent_pubkey: &str, + delete: impl FnOnce() -> Result, + clear: impl FnMut(&ScopedAssignment) -> Result<(), String>, + mut restore: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result { + if load_recovery_journal(base_dir)?.is_some() { + return Err("pending Bestie assignment recovery must complete before deletion".to_string()); + } + let assignments = matching_assignments(base_dir, agent_pubkey)?; + if assignments.is_empty() { + return delete(); + } + persist_recovery_journal(base_dir, &assignments)?; + if let Err(error) = clear_scoped_assignments(&assignments, clear) { + return rollback_with_journal(base_dir, &assignments, error, &mut restore); + } + match delete() { + Ok(value) => { + if let Err(error) = remove_recovery_journal(base_dir) { + // The authoritative managed-agent write already committed. + // Keep the journal as a durable cleanup record; launch/command + // recovery will observe the absent agent, re-clear these exact + // scopes idempotently, and retry journal removal. + eprintln!("buzz-desktop: {error}; cleanup will retry"); + } + Ok(value) + } + Err(error) => rollback_with_journal(base_dir, &assignments, error, &mut restore), + } +} + +/// Run agent deletion work with this agent's community-scoped Bestie +/// assignments temporarily cleared. +/// +/// Call this while holding `managed_agents_store_lock`. Every matching scope is +/// snapshotted before the first write. A partial clear, or any later stop/save +/// failure returned by `delete`, restores the snapshot before the error is +/// propagated. Assignments remain cleared only when `delete` succeeds. +pub fn with_agent_assignments_cleared( + base_dir: &Path, + agent_pubkey: &str, + delete: impl FnOnce() -> Result, +) -> Result { + with_agent_assignments_cleared_using(base_dir, agent_pubkey, delete, clear_scope, restore_scope) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connection() -> Connection { + Connection::open_in_memory().unwrap_or_else(|error| panic!("open test db: {error}")) + } + + #[test] + fn assignment_is_singleton_and_idempotent() { + let mut conn = connection(); + let first = replace_assignment(&mut conn, &"A".repeat(64)) + .unwrap_or_else(|error| panic!("assign first: {error}")); + assert_eq!(first.agent_pubkey, "a".repeat(64)); + + let same = replace_assignment(&mut conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("reassign same: {error}")); + assert_eq!(same.agent_pubkey, "a".repeat(64)); + + let replaced = replace_assignment(&mut conn, &"b".repeat(64)) + .unwrap_or_else(|error| panic!("replace: {error}")); + assert_eq!(replaced.agent_pubkey, "b".repeat(64)); + } + + #[test] + fn stale_resolver_is_fenced_after_replace_and_clear_is_idempotent() { + let mut conn = connection(); + replace_assignment(&mut conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("assign: {error}")); + replace_assignment(&mut conn, &"b".repeat(64)) + .unwrap_or_else(|error| panic!("replace: {error}")); + assert!(!assignment_matches(&conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("check stale assignment: {error}"))); + clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear: {error}")); + clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear again: {error}")); + assert_eq!( + get_assignment(&conn).unwrap_or_else(|error| panic!("read: {error}")), + None + ); + } + + #[test] + fn deleting_agent_clears_every_matching_scope_and_preserves_other_assignments() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + let other = "b".repeat(64); + let first_path = retention_dir.join("first.db"); + let second_path = retention_dir.join("second.db"); + let third_path = retention_dir.join("third.db"); + replace_assignment( + &mut open_retention_db(&first_path) + .unwrap_or_else(|error| panic!("open first db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign first scope: {error}")); + replace_assignment( + &mut open_retention_db(&second_path) + .unwrap_or_else(|error| panic!("open second db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign second scope: {error}")); + replace_assignment( + &mut open_retention_db(&third_path) + .unwrap_or_else(|error| panic!("open third db: {error}")), + &other, + ) + .unwrap_or_else(|error| panic!("assign third scope: {error}")); + + with_agent_assignments_cleared(dir.path(), &agent, || Ok(())) + .unwrap_or_else(|error| panic!("clear agent assignments: {error}")); + assert_eq!( + get_assignment( + &open_retention_db(&first_path) + .unwrap_or_else(|error| panic!("reopen first db: {error}")) + ) + .unwrap_or_else(|error| panic!("read first scope: {error}")), + None + ); + assert_eq!( + get_assignment( + &open_retention_db(&third_path) + .unwrap_or_else(|error| panic!("reopen third db: {error}")) + ) + .unwrap_or_else(|error| panic!("read third scope: {error}")) + .map(|assignment| assignment.agent_pubkey), + Some(other) + ); + } + + #[test] + fn later_scope_clear_failure_restores_the_already_cleared_prefix() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + for name in ["first.db", "second.db"] { + replace_assignment( + &mut open_retention_db(&retention_dir.join(name)) + .unwrap_or_else(|error| panic!("open {name}: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign {name}: {error}")); + } + + let result = with_agent_assignments_cleared_using( + dir.path(), + &agent, + || Ok(()), + |assignment| { + if assignment.path.ends_with("second.db") { + Err("injected later retention DB failure".to_string()) + } else { + clear_scope(assignment) + } + }, + restore_scope, + ); + + assert!(result.is_err()); + for name in ["first.db", "second.db"] { + let conn = open_retention_db(&retention_dir.join(name)) + .unwrap_or_else(|error| panic!("reopen {name}: {error}")); + assert!(assignment_matches(&conn, &agent) + .unwrap_or_else(|error| panic!("read {name}: {error}"))); + } + } + + fn assert_later_deletion_failure_restores_assignment(failure: &str) { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let path = retention_dir.join("owner.db"); + let agent = "a".repeat(64); + replace_assignment( + &mut open_retention_db(&path) + .unwrap_or_else(|error| panic!("open assignment db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign agent: {error}")); + + let result = with_agent_assignments_cleared(dir.path(), &agent, || { + Err::<(), _>(failure.to_string()) + }); + + assert_eq!(result, Err(failure.to_string())); + let conn = open_retention_db(&path) + .unwrap_or_else(|error| panic!("reopen assignment db: {error}")); + assert!(assignment_matches(&conn, &agent) + .unwrap_or_else(|error| panic!("read restored assignment: {error}"))); + } + + #[test] + fn stop_failure_after_cleanup_restores_assignment() { + assert_later_deletion_failure_restores_assignment("injected stop failure"); + } + + #[test] + fn save_failure_after_cleanup_restores_assignment() { + assert_later_deletion_failure_restores_assignment("injected save failure"); + } + + #[test] + fn failed_rollback_leaves_a_durable_journal_that_repairs_on_restart() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + let first_path = retention_dir.join("first.db"); + let second_path = retention_dir.join("second.db"); + for path in [&first_path, &second_path] { + replace_assignment( + &mut open_retention_db(path) + .unwrap_or_else(|error| panic!("open {}: {error}", path.display())), + &agent, + ) + .unwrap_or_else(|error| panic!("assign {}: {error}", path.display())); + } + + let result = with_agent_assignments_cleared_using( + dir.path(), + &agent, + || Err::<(), _>("injected managed-agent save failure".to_string()), + clear_scope, + |assignment| { + if assignment.path == second_path { + Err("injected restore failure".to_string()) + } else { + restore_scope(assignment) + } + }, + ); + + assert!(result + .as_ref() + .is_err_and(|error| error.contains("injected restore failure"))); + assert!(recovery_journal_path(dir.path()).exists()); + assert!(!assignment_matches( + &open_retention_db(&second_path) + .unwrap_or_else(|error| panic!("reopen second scope: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("read torn scope: {error}"))); + + recover_pending_assignment_cleanup(dir.path(), |pubkey| pubkey == agent) + .unwrap_or_else(|error| panic!("replay durable recovery: {error}")); + + for path in [&first_path, &second_path] { + assert!(assignment_matches( + &open_retention_db(path) + .unwrap_or_else(|error| panic!("reopen {}: {error}", path.display())), + &agent, + ) + .unwrap_or_else(|error| panic!("read repaired {}: {error}", path.display()))); + } + assert!(!recovery_journal_path(dir.path()).exists()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs index 647ea56209e..0871544dbc3 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -4,15 +4,10 @@ //! local Claude Code agents. `BUZZ_ACP_MODEL` is removed from the spawned //! env so the harness never sees two model authorities simultaneously. //! -//! B5 contract: `BUZZ_ACP_EFFORT_LEVEL` is the canonical persisted startup -//! effort authority for all local agents. Written after `descriptor.env` so -//! user-supplied entries cannot shadow a persisted canonical value. - -/// The spawn-time env var carrying startup effort. Shared by the spawn -/// application ([`apply_effort_env`]) and the snapshot projection -/// (`spawn_snapshot::effective_effort`) so the value the harness receives and -/// the value the restart badge compares are named from one place. -pub const EFFORT_LEVEL_ENV_VAR: &str = "BUZZ_ACP_EFFORT_LEVEL"; +//! Startup effort is no longer applied here: the harness-agnostic effort +//! projection (`config_bridge::effort`) runs inside the descriptor resolver, so +//! `descriptor.env` already carries exactly one effort key. See that module for +//! the single-authority contract, including the ACP-startup key constant. /// Apply the A1 model authority: inject `ANTHROPIC_MODEL` from `effective_model` /// (or remove it if `None`) and strip `BUZZ_ACP_MODEL` from the spawned env. @@ -33,21 +28,6 @@ pub fn apply_claude_model_env(command: &mut std::process::Command, effective_mod } } -/// Apply the B5 effort authority: inject `BUZZ_ACP_EFFORT_LEVEL` from -/// `effort_level` (or leave it untouched if `None`). -/// -/// Must be called after `descriptor.env` is written so the canonical persisted -/// value wins over any user-supplied `BUZZ_ACP_EFFORT_LEVEL` entry. When -/// `effort_level` is `None` there is no canonical value to assert; the command -/// env is left untouched so a user-supplied value from `descriptor.env` -/// legitimately seeds startup effort. -pub fn apply_effort_env(command: &mut std::process::Command, effort_level: Option<&str>) { - if let Some(e) = effort_level { - command.env(EFFORT_LEVEL_ENV_VAR, e); - } - // None: no canonical value — leave whatever descriptor.env wrote intact. -} - #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs index f6f0f90cb2d..0e596bc72b7 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -1,4 +1,4 @@ -use super::{apply_claude_model_env, apply_effort_env}; +use super::apply_claude_model_env; /// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after /// `apply_claude_model_env`, even if it was set before (dual-authority defect). @@ -54,74 +54,10 @@ fn a1_anthropic_model_removed_when_no_effective_model() { ); } -// ── B5 effort-authority contract tests ────────────────────────────────────── +// ── B5 effort-authority contract ───────────────────────────────────────────── // -// These tests verify that `apply_effort_env`, called after `descriptor.env`, -// makes the canonical persisted effort win over any user-supplied value. - -/// B5 (local): canonical effort wins when user env supplies a conflicting value. -/// Simulates the defect scenario: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low, -/// then apply_effort_env is called with the canonical "high". The canonical value -/// must be what survives in the spawned-child env. -#[test] -fn b5_canonical_effort_wins_over_user_env_collision() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env writing a user-supplied value (the pre-fix - // ordering: effort written before the loop, then loop overwrote it, or - // equivalently: effort written post-loop but with user value also post-loop). - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // Post-loop canonical application — the fix. - apply_effort_env(&mut cmd, Some("high")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "high", - "canonical effort must win over the user-supplied 'low' — B5 authority ordering" - ); -} - -/// B5 (local): when no canonical effort is persisted (effort_level is None), -/// user env passthrough is preserved — the descriptor.env entry seeds startup effort. -/// Simulates: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low (already in command), -/// then apply_effort_env(None) is called — user value must survive. -#[test] -fn b5_user_effort_env_survives_when_no_canonical_value() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env loop having written a user-supplied value first. - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // No canonical value — apply_effort_env(None) is a no-op so the user - // value already written by the descriptor.env loop survives intact. - apply_effort_env(&mut cmd, None); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "low", - "user-supplied effort must survive when no canonical value is persisted" - ); -} - -/// B5 (local): canonical effort is present in the spawned env even when user -/// env did NOT supply a conflicting value (basic injection contract). -#[test] -fn b5_canonical_effort_injected_when_no_user_collision() { - let mut cmd = std::process::Command::new("true"); - // No user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env. - apply_effort_env(&mut cmd, Some("medium")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "medium", - "canonical effort must be injected when no collision" - ); -} +// Startup-effort application moved out of this module into the single +// harness-agnostic projection (`config_bridge::effort`). Its authority, +// collision, and single-key contract is exercised by +// `config_bridge::effort::tests`; there is no longer a Claude-local effort +// helper to test here. diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs new file mode 100644 index 00000000000..e06fe06216d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -0,0 +1,506 @@ +//! The single harness-agnostic effort authority (plan-of-record, PR #4625). +//! +//! ## One projection, one destination key, one snapshot leaf +//! +//! [`effort_launch_projection`] resolves the effective startup effort a spawn +//! would apply, over the canonical persisted column (`record.effort_level`) AND +//! the sanitized per-tier env inputs, in the CLEAR authority order: +//! +//! ```text +//! record native(valid) > canonical column(valid) > record legacy(valid) +//! > persona(native, then legacy) > global(native) > definition(native) +//! > baked(native) +//! ``` +//! +//! (The reader adds the live-ACP tier between column and persona and the config +//! file tier at the bottom; the launch projection has neither — a spawn reads +//! neither a running session nor the on-disk harness file.) +//! +//! The **tier-reading** native key is the runtime's real `thinking_env_var` +//! (`None` for Claude/Codex — those have no native key, so the column is the +//! sole authority and a user-supplied `BUZZ_ACP_EFFORT_LEVEL` is transport, not +//! a tier). The **emission** key ([`EffortLaunch::key`]) is +//! `thinking_env_var.unwrap_or(BUZZ_ACP_EFFORT_LEVEL)`: Goose emits +//! `GOOSE_THINKING_EFFORT`, buzz-agent emits `BUZZ_AGENT_THINKING_EFFORT`, +//! Claude/Codex/keyless-ACP and any unknown/custom runtime emit the retained +//! ACP-startup sentinel `BUZZ_ACP_EFFORT_LEVEL`. +//! +//! [`EffortLaunch::suppress`] lists every known native/legacy effort key plus +//! the sentinel; every consumer strips them all first, then emits at most the +//! one `key`. This is what guarantees a launched process, a remote payload, and +//! a restart snapshot can never carry two effort authorities. + +use std::collections::BTreeMap; + +use super::LEGACY_THINKING_EFFORT_KEY; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::{EffortNormalization, KnownAcpRuntime}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +/// The retained ACP-startup transport key. Claude, Codex, keyless ACP adapters, +/// and any unknown/custom runtime route the effective effort through this key +/// (the harness reads it into `PoolStartup.startup_effort`). It is *transport*, +/// never a value-authority tier: a user-supplied entry is suppressed and +/// overwritten by the projected effective value. +pub(crate) const ACP_STARTUP_EFFORT_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +/// The resolved launch effort for one runtime: the single fact every spawn +/// path (local, remote, snapshot) consumes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EffortLaunch { + /// The final effective effort value, normalized for contract runtimes and + /// raw for contract-less ones, resolved over ALL tiers (column + env). + /// `None` when no tier supplies a value the destination can express. + pub value: Option, + /// The destination env key the value is emitted under. + pub key: &'static str, + /// Every effort key to strip from the launch env before emitting `key`. + /// Always includes the sentinel and all known native/legacy effort keys, so + /// no foreign or transport effort key can shadow the projected authority. + pub suppress: Vec<&'static str>, + /// When no tier resolved a `value`, preserve a value the launch env already + /// carries under `key` (collapsing every case variant to the canonical + /// spelling). Set only for unknown/custom runtimes, where the ACP sentinel + /// is user pass-through transport that must survive a spawn — not a foreign + /// key to drop. Known runtimes leave it `false`: a bare destination-key + /// value with no resolved authority is invalid/foreign and is dropped. + pub preserve_passthrough: bool, +} + +impl EffortLaunch { + /// Apply the projection to a launch env map: strip every `suppress` key, + /// then emit `key = value` when a value is present. After this call the map + /// holds at most one effort key (`key`), carrying the effective value. + /// + /// Suppression is ASCII-case-insensitive: Windows `Command` case-folds env + /// names, so a hand-set `goose_thinking_effort` would otherwise evade an + /// exact-case strip and shadow the projected authority. + /// + /// When `preserve_passthrough` is set and no tier resolved a value, a value + /// already present under `key` (in any case) is carried forward and + /// re-emitted canonically. Multiple case spellings can survive the + /// case-sensitive layer merge (e.g. a lower-tier `BUZZ_ACP_EFFORT_LEVEL` + /// plus a higher-tier `buzz_acp_effort_level`); the carry selects the LAST + /// case-insensitive match in `BTreeMap` iteration order, which is exactly + /// the value Rust's Windows `Command` writer produces — it sets each spelling + /// in iteration order into a case-folded env map, so the last set wins. This + /// keeps an unknown/custom runtime's hand-set sentinel alive, preserves the + /// value the child would actually receive, and guarantees one canonical + /// spelling downstream. + pub(crate) fn apply(&self, env: &mut BTreeMap) { + let carried = (self.value.is_none() && self.preserve_passthrough) + .then(|| { + env.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(self.key)) + .map(|(_, v)| v.clone()) + }) + .flatten(); + env.retain(|k, _| { + !self + .suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); + if let Some(v) = self.value.as_ref().or(carried.as_ref()) { + env.insert(self.key.to_string(), v.clone()); + } + } +} + +/// Look up `key` in `map` case-insensitively (ASCII), selecting the LAST +/// case-insensitive match in `BTreeMap` iteration order. Effort key resolution +/// must match Windows `Command` env semantics: `Command` writes each spelling +/// in iteration order into a case-folded env map, so the last-set spelling wins +/// and is the value the child actually receives. Preferring an exact match +/// instead would pick a different case variant than the child gets — e.g. +/// `GOOSE_THINKING_EFFORT=low` plus `goose_thinking_effort=high` would resolve +/// to `low` while the child runs `high`. This mirrors `EffortLaunch::apply`'s +/// `.rev().find` carry so the tier reader, the passthrough carry, and the child +/// all agree on one value. +pub(crate) fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { + map.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v) +} + +/// Resolve the single harness-agnostic effort authority and apply it to a fully +/// layered launch `env`: strip every known/legacy/transport effort key, then +/// emit exactly the one destination key holding the effective value. Called by +/// the descriptor resolver AFTER the full layer stack, so the launch env, the +/// remote deploy payload, and the restart snapshot all carry one effort key and +/// one value — no double authority, no foreign key, no launch/badge disagreement. +#[allow(clippy::too_many_arguments)] +pub(crate) fn apply_launch_effort( + env: &mut BTreeMap, + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) { + effort_launch_projection( + record, + runtime, + personas, + record.persona_id.as_deref(), + global_env, + harness_def, + baked_env, + ) + .apply(env); +} + +/// Resolve one effort tier's value, applying within-tier legacy aliasing and +/// normalization. Returns the canonical (or raw, contract-less) value, or +/// `None` when no usable candidate exists. +/// +/// Lookup (per tier, independent of other tiers): +/// 1. Native key — normalized; invalid → skip as absent. +/// 2. Legacy key (`BUZZ_AGENT_THINKING_EFFORT`) — only when the native key +/// differs from it AND `allow_legacy_alias` is set AND the value +/// normalizes. Invalid legacy is skipped so the next tier can supply one. +pub(crate) fn effort_tier_alias( + map: &BTreeMap, + native_key: &str, + norm: impl Fn(&str) -> Option, + allow_legacy_alias: bool, +) -> Option { + if let Some(raw) = get_ci(map, native_key) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + if allow_legacy_alias && native_key != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = get_ci(map, LEGACY_THINKING_EFFORT_KEY) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + } + None +} + +/// Normalize/validate an effort candidate for a runtime's destination +/// vocabulary. The single value gate shared by the launch projection and the +/// reader, so the panel and the next spawn never disagree on a value's validity. +/// +/// - `contract` present (Goose): canonicalize through the alias table; invalid +/// → `None` (skip as absent). +/// - `contract` absent but `accepted` present (buzz-agent): validation-only — +/// accept a value case-insensitively iff the destination parser would +/// (`parse_thinking_effort`), emit it lowercased; a foreign canonical (e.g. +/// Goose `off`) is rejected so it is never emitted as +/// `BUZZ_AGENT_THINKING_EFFORT=off`, which crashes the child at config init. +/// - both absent (Claude/Codex, unknown/custom): raw passthrough — the value +/// rides `BUZZ_ACP_EFFORT_LEVEL` to an adapter that accepts any string. +pub(crate) fn normalize_effort( + contract: Option<&EffortNormalization>, + accepted: Option<&[&str]>, + raw: &str, +) -> Option { + match contract { + Some(c) => c.normalize_str(raw), + None => match accepted { + Some(values) => { + let lower = raw.trim().to_ascii_lowercase(); + values.iter().any(|v| *v == lower).then_some(lower) + } + None => Some(raw.to_string()), + }, + } +} + +/// The destination env key the effective effort is emitted under for `runtime`: +/// the runtime's native `thinking_env_var`, else the ACP-startup sentinel +/// (Claude, Codex, keyless ACP adapters, and unknown/custom runtimes). +pub(crate) fn effort_dest_key(runtime: Option<&KnownAcpRuntime>) -> &'static str { + runtime + .and_then(|r| r.thinking_env_var) + .unwrap_or(ACP_STARTUP_EFFORT_KEY) +} + +/// Every effort key to strip before emitting the single destination key: all +/// known native effort keys, the legacy alias, and the ACP-startup sentinel. +/// Stripping the full set guarantees no foreign or transport effort key can +/// shadow the projected authority. +pub(crate) fn effort_suppress_keys() -> Vec<&'static str> { + let mut keys: Vec<&'static str> = super::all_known_effort_keys().collect(); + if !keys.contains(&ACP_STARTUP_EFFORT_KEY) { + keys.push(ACP_STARTUP_EFFORT_KEY); + } + if !keys.contains(&LEGACY_THINKING_EFFORT_KEY) { + keys.push(LEGACY_THINKING_EFFORT_KEY); + } + keys +} + +/// Strip every known effort key from a [`std::process::Command`] before the +/// descriptor overlay is written. +/// +/// Only used in tests to verify tombstone assertions on individual keys. +/// Production stripping runs inside `apply_effort_launch_to_command` +/// (the loop over `launch.suppress`) which is exercised by the +/// production-sequence tests. +#[cfg(test)] +pub(crate) fn strip_effort_keys_from_command(cmd: &mut std::process::Command) { + for key in effort_suppress_keys() { + cmd.env_remove(key); + // Belt-and-suspenders for Unix inherited env with non-canonical casing + // (e.g. a shell export of `goose_thinking_effort`). Our own cmd.env() + // calls always use UPPER_SNAKE_CASE; only ambient inherited keys can + // arrive in non-standard case on Unix. + let lower = key.to_ascii_lowercase(); + if lower != key { + cmd.env_remove(&lower); + } + } +} + +/// Strip effort keys and emit the projected effort value to a +/// [`std::process::Command`]. +/// +/// This is the production command-boundary seam: call after +/// `build_buzz_agent_provider_defaults` (which writes raw baked env) and +/// before the `descriptor.env` loop (which overlays the projected key). +/// Extracting both steps into one call lets tests exercise the full +/// baked-write → strip → emit sequence and inspect the child's effective +/// environment, making the test fail if either step is removed or misordered +/// in production. +/// +/// Strip policy follows `launch.suppress`: for known runtimes that is the full +/// effort vocabulary; for unknown/custom runtimes it is only the ACP sentinel, +/// leaving foreign effort keys (e.g. a wrapper's own `GOOSE_THINKING_EFFORT`) +/// untouched. Each key is stripped in canonical and lowercase form so ambient +/// inherited env with non-canonical casing is swept on Unix. +/// +/// When `launch.preserve_passthrough` is set and `launch.value` is `None` +/// (unknown runtime, no authoritative column), the suppress set is skipped +/// entirely: the inherited process env carries the user's hand-set sentinel, +/// and stripping it here without a re-emit would silently drop it. Known +/// runtimes always have a resolved `value` or do not set `preserve_passthrough`. +pub(crate) fn apply_effort_launch_to_command( + cmd: &mut std::process::Command, + launch: &EffortLaunch, +) { + // For unknown/custom runtimes with no resolved value the suppress set is + // only the ACP sentinel, and stripping it without re-emitting would destroy + // the user's ambient pass-through config. Skip the strip entirely and let + // the inherited env carry it through unchanged. + // MUTATION: removing this guard strips the sentinel and breaks + // `production_sequence_custom_inherited_acp_sentinel_survives`. + if launch.preserve_passthrough && launch.value.is_none() { + return; + } + for key in &launch.suppress { + cmd.env_remove(key); + let lower = key.to_ascii_lowercase(); + if lower.as_str() != *key { + cmd.env_remove(&lower); + } + } + if let Some(ref value) = launch.value { + cmd.env(launch.key, value); + } +} + +/// The effort keys the restart snapshot must strip from its captured launch env +/// so effort keeps exactly ONE representation (`effort_level`), mirroring what +/// [`effort_launch_projection`] actually suppressed for `runtime`: +/// +/// - **known runtime** — the full suppress set. The projection already swept +/// every effort key to the single destination key, so this removes only that +/// destination key (a no-op on the already-swept siblings). +/// - **unknown/custom runtime** — only the ACP-startup sentinel. The projection +/// suppresses just the sentinel here (reconciling every case variant to the +/// canonical spelling — external review, Carl P2), leaving every other +/// effort-looking key (e.g. a hand-rolled `GOOSE_THINKING_EFFORT`) untouched +/// as ordinary env. Those must remain in `env` so an edit to them diffs the +/// snapshot normally; only the sentinel — the key the projection emits and +/// `effective_effort` reads into `effort_level` — is removed. +pub(crate) fn snapshot_suppress_keys(runtime: Option<&KnownAcpRuntime>) -> Vec<&'static str> { + if runtime.is_some() { + effort_suppress_keys() + } else { + vec![effort_dest_key(runtime)] + } +} + +/// Build the single effective-effort projection for a launch. +/// +/// `global_env`, `persona_id`+`personas`, `harness_def`, and `baked_env` supply +/// the same per-tier inputs the layered spawn env is built from; the projection +/// re-reads them so an invalid high-tier value skips as absent and a lower tier +/// can win (which a merged last-wins env map cannot express). +pub(crate) fn effort_launch_projection( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> EffortLaunch { + let key = effort_dest_key(runtime); + + // Suppress the full effort vocabulary for KNOWN runtimes. For an + // unknown/custom runtime (external review #2) we keep every foreign + // effort-looking key as pass-through — a hand-rolled `GOOSE_THINKING_EFFORT` + // on a custom Goose wrapper must reach the child untouched — EXCEPT our own + // ACP-startup sentinel, which we always reconcile to a single canonical + // spelling (external review, Carl P2): the projection emits the sentinel, so + // a user-set case variant (e.g. `buzz_acp_effort_level`) is never intentional + // config, and leaving one to shadow the emitted `BUZZ_ACP_EFFORT_LEVEL` on + // Windows (where `Command` case-folds env names) would hand the child a + // different value than the snapshot reads. Stripping the sentinel here and + // re-emitting canonically guarantees at most ONE sentinel spelling downstream, + // so the child, the restart snapshot, and the badge cannot disagree on case. + let suppress = if runtime.is_some() { + effort_suppress_keys() + } else { + vec![ACP_STARTUP_EFFORT_KEY] + }; + // When no tier resolves a value, an unknown runtime still preserves a + // hand-set sentinel the user routed to the child (the retained pass-through + // from external review #2) — carried forward and re-emitted canonically by + // `apply`. Known runtimes never preserve a bare dest-key value: it is either + // the projection's own emission or a foreign key, both handled by `value`. + let preserve_passthrough = runtime.is_none(); + + // Value gate: Goose canonicalizes through its alias contract; buzz-agent + // validates against its accepted set (invalid → skip, so a foreign + // canonical like Goose `off` is never emitted where the destination parser + // rejects it); Claude/Codex and unknown/custom pass raw over the sentinel. + let contract = runtime.and_then(|r| r.effort_normalization); + let accepted = runtime.and_then(|r| r.effort_accepted_values); + let norm = |raw: &str| -> Option { normalize_effort(contract, accepted, raw) }; + + // Tier-reading native key: the runtime's REAL native key. `None` (Claude, + // Codex, unknown/custom) means there are no env-tier authorities — the + // sentinel in user env is transport only — so the column is the sole source. + let native_key = runtime.and_then(|r| r.thinking_env_var); + + let value = resolve_effective_effort( + record, + native_key, + &norm, + personas, + persona_id, + global_env, + harness_def, + baked_env, + ); + + EffortLaunch { + value, + key, + suppress, + preserve_passthrough, + } +} + +/// Resolve the effective effort value in CLEAR authority order (launch tiers). +#[allow(clippy::too_many_arguments)] +fn resolve_effective_effort( + record: &ManagedAgentRecord, + native_key: Option<&str>, + norm: &impl Fn(&str) -> Option, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> Option { + use crate::managed_agents::env_vars::{is_reserved_env_key, live_persona_env, merged_user_env}; + + // Sanitize env tiers exactly as the layered spawn env does (reserved/ + // malformed/NUL filtering), so the resolved authority matches what launches. + let record_env = merged_user_env(&BTreeMap::new(), &record.env_vars); + + // 1. record native — only for runtimes with a real native key. + if let Some(nk) = native_key { + if let Some(raw) = get_ci(&record_env, nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + // 2. canonical column — normalized (raw passthrough for contract-less). + if let Some(raw) = record.effort_level.as_deref() { + if let Some(v) = norm(raw) { + return Some(v); + } + } + // 3. record legacy alias — only when the native key differs from it. + if let Some(nk) = native_key { + if nk != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = get_ci(&record_env, LEGACY_THINKING_EFFORT_KEY) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + } + // Env tiers below require a native key to read. + let nk = native_key?; + + // 4. persona (native, then legacy) — sanitized like the layered spawn env. + let persona_env = merged_user_env(&BTreeMap::new(), &live_persona_env(personas, persona_id)); + if let Some(v) = effort_tier_alias(&persona_env, nk, norm, true) { + return Some(v); + } + // 5. global (native only). + let global = merged_user_env(&BTreeMap::new(), global_env); + if let Some(v) = effort_tier_alias(&global, nk, norm, false) { + return Some(v); + } + // 6. definition (native only) — author-controlled; reserved keys stripped. + if let Some(def) = harness_def { + let def_env: BTreeMap = def + .env + .iter() + .filter(|(k, _)| !is_reserved_env_key(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if let Some(v) = effort_tier_alias(&def_env, nk, norm, false) { + return Some(v); + } + } + // 7. baked build floor (native only). + if let Some(raw) = get_ci(baked_env, nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + None +} + +/// Combined spawn seam: baked-env write + effort strip + emit. +/// +/// Called by `apply_effort_to_spawn_command` in `runtime.rs` (production path) +/// and by `effort_cmd_tests` (test seam). Deleting `build_buzz_agent_provider_defaults` +/// or `apply_effort_launch_to_command` inside turns the production-sequence tests RED. +/// Deleting the outer `apply_effort_to_spawn_command` call from `spawn_agent_child` +/// is a compile error — `spawn_with_effort_proof` consumes the returned `EffortApplied` +/// token, so removing the binding leaves `effort` undefined at the spawn site. +pub(crate) fn apply_spawn_effort_env( + cmd: &mut std::process::Command, + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + baked_env: &BTreeMap, +) { + crate::managed_agents::agent_env::build_buzz_agent_provider_defaults(cmd); + let launch = effort_launch_projection( + record, runtime, personas, persona_id, global_env, None, baked_env, + ); + apply_effort_launch_to_command(cmd, &launch); +} + +#[cfg(test)] +#[path = "effort_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs new file mode 100644 index 00000000000..172f373d79d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs @@ -0,0 +1,356 @@ +//! Command-boundary strip and production-sequence seam tests for effort. +//! +//! Split from `effort_tests.rs` to stay within the file-size ratchet. +//! Covers `strip_effort_keys_from_command` tombstone assertions and the +//! child-process spawn sequence via `apply_effort_to_spawn_command`. +//! +//! The production-sequence tests call `apply_effort_to_spawn_command` +//! (`runtime.rs`), the same function `spawn_agent_child` calls. Deleting +//! `apply_spawn_effort_env` from that wrapper turns these tests RED. +//! Deleting the `apply_effort_to_spawn_command` call from `spawn_agent_child` +//! is a compile error: `spawn_with_effort_proof` consumes the returned +//! `EffortApplied` by value, so removing the binding leaves `effort` undefined +//! at the spawn site. + +use std::collections::BTreeMap; + +use super::super::strip_effort_keys_from_command; +use super::*; +use crate::managed_agents::runtime::apply_effort_to_spawn_command; + +// -------------------------------------------------------------------------- +// Command-boundary strip (P1: inherited + baked collision) +// -------------------------------------------------------------------------- + +/// ACP sentinel baked/inherited collision: registered for removal after strip. +#[test] +fn strip_removes_baked_acp_sentinel_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(ACP_KEY, "high"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == ACP_KEY && value.is_none()); + assert!( + removed, + "ACP sentinel must be registered for removal after strip" + ); +} + +/// Baked `GOOSE_THINKING_EFFORT` collision: stripped before descriptor overlay. +#[test] +fn strip_removes_baked_goose_native_key_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(GOOSE_KEY, "high"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == GOOSE_KEY && value.is_none()); + assert!( + removed, + "GOOSE_THINKING_EFFORT must be registered for removal after strip" + ); +} + +/// Baked `BUZZ_AGENT_THINKING_EFFORT` collision: legacy alias stripped. +#[test] +fn strip_removes_baked_buzz_agent_native_key_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(BUZZ_AGENT_KEY, "medium"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == BUZZ_AGENT_KEY && value.is_none()); + assert!( + removed, + "BUZZ_AGENT_THINKING_EFFORT must be registered for removal after strip" + ); +} + +/// Lowercase inherited key: both canonical and lowercase variants are stripped. +#[test] +fn strip_removes_lowercase_goose_key_inherited_from_shell() { + let lower = GOOSE_KEY.to_ascii_lowercase(); + let mut cmd = std::process::Command::new("echo"); + cmd.env(&lower, "stale"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == lower.as_str() && value.is_none()); + assert!( + removed, + "lowercase GOOSE key must be registered for removal" + ); +} + +/// Custom passthrough: non-suppress-set keys are not removed. +#[test] +fn strip_does_not_remove_unrelated_env_key() { + let mut cmd = std::process::Command::new("echo"); + cmd.env("MY_CUSTOM_EFFORT", "high"); + strip_effort_keys_from_command(&mut cmd); + let value_present = cmd + .get_envs() + .any(|(key, value)| key == "MY_CUSTOM_EFFORT" && value.is_some()); + assert!( + value_present, + "strip must not touch env keys outside the suppress set" + ); +} + +// -------------------------------------------------------------------------- +// Production-sequence seam tests +// -------------------------------------------------------------------------- +// Spawn the child directly so its actual env is the ground truth. +// These call `apply_effort_to_spawn_command` (in `runtime.rs`), the same function +// `spawn_agent_child` calls. Deleting `apply_spawn_effort_env` from that wrapper +// turns these tests RED. The `EffortApplied` sentinel makes the call site in +// `spawn_agent_child` a compile-time requirement. +// Deletion proofs: +// - remove `build_buzz_agent_provider_defaults` inside → baked keys leak; +// - remove `effort_launch_projection` → suppress list is empty, keys leak; +// - remove `apply_effort_launch_to_command` → stale keys remain, assertion fails. +// +// Inherited-state tests seed the parent env via `std::env::set_var` under the +// crate-wide env lock (`crate::managed_agents::lock_env_mutex`). `EnvVarGuard` +// restores the exact prior value (including non-Unicode) in `Drop`, so panics +// do not leak the seeded value into unrelated child-spawn tests. + +/// RAII guard: snapshots a process-env variable and restores the exact prior +/// value (or removes it if it was absent) on `Drop`, even on panic. +/// Uses `OsString` so a pre-existing non-Unicode value is restored exactly +/// rather than being silently lost. +struct EnvVarGuard { + key: String, + prior: Option, +} +impl EnvVarGuard { + fn set(key: &str, value: &str) -> Self { + let prior = std::env::var_os(key); + #[allow(deprecated)] + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + prior, + } + } +} +impl Drop for EnvVarGuard { + fn drop(&mut self) { + #[allow(deprecated)] + unsafe { + match &self.prior { + Some(v) => std::env::set_var(&self.key, v), + None => std::env::remove_var(&self.key), + } + } + } +} + +fn run_env_cmd(cmd: &mut std::process::Command) -> String { + let output = cmd + .output() + .expect("env-dump command must be executable on this host"); + assert!( + output.status.success(), + "env command failed: {:?}", + output.status + ); + String::from_utf8_lossy(&output.stdout).to_string() +} + +/// After projection + strip + emit, the child sees exactly the projected Goose +/// key with no collision. Inherited lowercase key is seeded via EnvVarGuard. +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_goose_inherited_collision_resolved_in_child() { + let lower = GOOSE_KEY.to_ascii_lowercase(); + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(&lower, "inherited-low"); + + let mut cmd = std::process::Command::new("/usr/bin/env"); + cmd.env(GOOSE_KEY, "baked-high"); + cmd.env(BUZZ_AGENT_KEY, "legacy-medium"); + cmd.env("MY_AGENT_CONFIG", "keep-me"); + + let mut r = record(); + r.effort_level = Some("high".into()); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &r, + Some(goose()), + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + + assert!( + child_env.contains(&format!("{GOOSE_KEY}=high")), + "child must receive the projected Goose key; env:\n{child_env}" + ); + assert!( + !child_env.contains(BUZZ_AGENT_KEY), + "legacy buzz-agent key must not reach child; env:\n{child_env}" + ); + assert!( + !child_env.contains(ACP_KEY), + "ACP sentinel must not reach child for Goose; env:\n{child_env}" + ); + assert!( + !child_env.contains(&format!("{lower}=inherited-low")), + "inherited lowercase key must be stripped; env:\n{child_env}" + ); + assert!( + child_env.contains("MY_AGENT_CONFIG=keep-me"), + "unrelated key must survive; env:\n{child_env}" + ); + let effort_key_count = [GOOSE_KEY, BUZZ_AGENT_KEY, ACP_KEY] + .iter() + .filter(|k| child_env.contains(&format!("{k}="))) + .count(); + assert_eq!( + effort_key_count, 1, + "exactly one effort key must reach child; env:\n{child_env}" + ); +} + +/// Windows: OS case-folds env keys, so stripping canonical removes ALL case variants. +#[test] +#[cfg(target_os = "windows")] +fn production_sequence_arbitrary_mixedcase_collision_absent_from_child_windows() { + let mixed = "GoOsE_ThInKiNg_EfFoRt"; + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/c", "set"]); + cmd.env_clear(); + cmd.env(mixed, "stale-mixed"); + let mut r = record(); + r.effort_level = Some("high".into()); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &r, + Some(goose()), + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env + .to_ascii_uppercase() + .contains(&format!("{}=HIGH", GOOSE_KEY.to_ascii_uppercase())), + "canonical effort key must reach the child; env:\n{child_env}" + ); + assert!( + !child_env + .to_ascii_uppercase() + .contains(&format!("{}=STALE-MIXED", mixed.to_ascii_uppercase())), + "mixed-case effort key must not reach the child; env:\n{child_env}" + ); +} + +/// Custom passthrough: non-suppress-set effort keys survive the production sequence. +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_passthrough_survives() { + let mut cmd = std::process::Command::new("/usr/bin/env"); + cmd.env_clear(); + cmd.env("MY_HARNESS_EFFORT", "high"); + cmd.env("MY_UNRELATED_CONFIG", "keep"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains("MY_HARNESS_EFFORT=high"), + "custom key must survive; env:\n{child_env}" + ); + assert!( + child_env.contains("MY_UNRELATED_CONFIG=keep"), + "unrelated key must survive; env:\n{child_env}" + ); +} + +/// Custom-runtime: inherited `GOOSE_THINKING_EFFORT` survives (unknown-runtime +/// suppress set excludes foreign effort keys). +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_inherited_goose_key_survives() { + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(GOOSE_KEY, "inherited-high"); + let mut cmd = std::process::Command::new("/usr/bin/env"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains(&format!("{GOOSE_KEY}=inherited-high")), + "GOOSE key must survive for unknown runtime; env:\n{child_env}" + ); +} + +/// Custom-runtime: inherited ACP sentinel survives as pass-through (no column). +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_inherited_acp_sentinel_survives() { + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(ACP_KEY, "inherited-val"); + let mut cmd = std::process::Command::new("/usr/bin/env"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains(&format!("{ACP_KEY}=inherited-val")), + "ACP sentinel must survive for unknown runtime with no column; env:\n{child_env}" + ); +} + +/// Windows: custom-wrapper effort keys survive the production sequence. +#[test] +#[cfg(target_os = "windows")] +fn production_sequence_custom_passthrough_survives() { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/c", "set"]); + cmd.env_clear(); + cmd.env("MY_HARNESS_EFFORT", "high"); + cmd.env("MY_UNRELATED_CONFIG", "keep"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!(child_env + .to_ascii_uppercase() + .contains("MY_HARNESS_EFFORT=HIGH")); + assert!(child_env + .to_ascii_uppercase() + .contains("MY_UNRELATED_CONFIG=KEEP")); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs new file mode 100644 index 00000000000..9c4568fceb4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -0,0 +1,701 @@ +//! Parity matrix for the single harness-agnostic effort projection +//! (`effort_launch_projection`, PR #4625). +//! +//! Covers, per runtime: CLEAR authority order; decisive mixed-authority; +//! `value == None` when no tier resolves; single-key emission + suppress; +//! unknown/custom-runtime ACP-sentinel fallback. + +use std::collections::BTreeMap; + +use super::{effort_launch_projection, effort_suppress_keys, EffortLaunch}; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::{known_acp_runtime_exact, KnownAcpRuntime}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +pub(super) const GOOSE_KEY: &str = "GOOSE_THINKING_EFFORT"; +pub(super) const BUZZ_AGENT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; +pub(super) const ACP_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +pub(super) fn goose() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("goose").expect("goose runtime in catalog") +} +fn claude() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("claude").expect("claude runtime in catalog") +} +fn buzz_agent() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("buzz-agent").expect("buzz-agent runtime in catalog") +} + +pub(super) fn record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "test".to_string(), + name: "Test Agent".to_string(), + persona_id: None, + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + description: 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: 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_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + team_catalog_source: 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: 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, + effort_level: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } +} + +fn env(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn persona(id: &str, env_vars: BTreeMap) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "P".to_string(), + avatar_url: None, + description: None, + system_prompt: String::new(), + runtime: None, + 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, + team_catalog_source: None, + env_vars, + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn harness_def(env: BTreeMap) -> HarnessDefinition { + HarnessDefinition { + id: "custom".to_string(), + label: "Custom".to_string(), + command: "custom".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +/// Convenience: project with no persona/global/definition/baked tiers. +fn project_record_only( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, +) -> EffortLaunch { + effort_launch_projection( + record, + runtime, + &[], + None, + &BTreeMap::new(), + None, + &BTreeMap::new(), + ) +} + +// -------------------------------------------------------------------------- +// Destination key + emission strategy per runtime +// -------------------------------------------------------------------------- + +#[test] +fn goose_emits_only_goose_key() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, GOOSE_KEY); +} + +#[test] +fn claude_routes_canonical_through_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(claude())); + // Claude has no native key: the column is the sole authority and it emits + // under the retained ACP-startup sentinel. + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +#[test] +fn buzz_agent_passes_raw_contract_less_value_under_native_key() { + let mut r = record(); + // buzz-agent has no static normalization contract: a per-model value that + // Goose would reject (e.g. "minimal") passes through raw. + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!(launch.value.as_deref(), Some("minimal")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); +} + +#[test] +fn unknown_runtime_falls_back_to_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + // No runtime metadata (custom/unknown adapter): preserve main's behavior — + // canonical routes through the raw ACP sentinel path. + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// CLEAR authority order + the decisive mixed-authority case +// -------------------------------------------------------------------------- + +#[test] +fn decisive_record_native_outranks_a_different_valid_column() { + // The mixed-authority pin Thufir/Will require: a valid record-native env + // key and a DIFFERENT valid canonical column must resolve to the + // record-native value — reader, local, remote, and snapshot all agree. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "record-native env outranks the canonical column" + ); +} + +#[test] +fn canonical_column_wins_when_no_record_native() { + // No record-native key present: the column is the next tier and wins over + // lower tiers (here, persona). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("high".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn record_legacy_alias_wins_over_persona_for_goose() { + // Record legacy `BUZZ_AGENT_THINKING_EFFORT` outranks persona for a runtime + // whose native key differs from the legacy key. + let mut r = record(); + r.persona_id = Some("p".into()); + r.env_vars = env(&[(BUZZ_AGENT_KEY, "max")]); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn persona_then_global_then_definition_then_baked_fall_through() { + // With no record tier set, each lower tier wins in order once the ones + // above it are absent. Verify persona > global by presence. + let mut r = record(); + r.persona_id = Some("p".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let global = env(&[(GOOSE_KEY, "low")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "persona outranks global" + ); + + // Drop the persona value: global wins. + let personas = vec![persona("p", BTreeMap::new())]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "global outranks definition" + ); + + // Drop global too: definition wins. + let def = harness_def(env(&[(GOOSE_KEY, "medium")])); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + Some(&def), + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("medium"), + "definition outranks baked" + ); + + // Drop definition: baked build floor wins. + let baked = env(&[(GOOSE_KEY, "off")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &baked, + ); + assert_eq!(launch.value.as_deref(), Some("off")); +} + +// -------------------------------------------------------------------------- +// Normalization + skip-as-absent fall-through +// -------------------------------------------------------------------------- + +#[test] +fn goose_alias_column_xhigh_normalizes_to_max() { + let mut r = record(); + r.effort_level = Some("xhigh".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn invalid_goose_column_skips_and_falls_through_to_persona() { + // "minimal" is invalid for Goose: it skips as absent so the persona tier + // supplies the effective value (nondestructive switch policy relies on this). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("minimal".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn invalid_goose_value_with_no_lower_tier_is_none() { + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value, None, + "invalid canonical with no fallback → None" + ); +} + +#[test] +fn no_tier_set_is_none() { + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); +} + +// -------------------------------------------------------------------------- +// Suppression + single-key emission (the double-authority guard) +// -------------------------------------------------------------------------- + +#[test] +fn suppress_covers_all_native_legacy_and_sentinel_keys() { + let keys = effort_suppress_keys(); + assert!(keys.contains(&GOOSE_KEY), "goose native key suppressed"); + assert!( + keys.contains(&BUZZ_AGENT_KEY), + "buzz-agent native + legacy key suppressed" + ); + assert!(keys.contains(&ACP_KEY), "ACP transport sentinel suppressed"); +} + +#[test] +fn apply_strips_every_foreign_effort_key_then_emits_one() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + let mut launch_env = env(&[ + (ACP_KEY, "stale"), + (BUZZ_AGENT_KEY, "stale"), + (GOOSE_KEY, "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get(ACP_KEY), None); + assert_eq!(launch_env.get(BUZZ_AGENT_KEY), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); + let effort_keys = launch_env + .keys() + .filter(|k| effort_suppress_keys().contains(&k.as_str())) + .count(); + assert_eq!(effort_keys, 1, "exactly one effort key survives"); +} + +#[test] +fn apply_with_no_value_strips_all_effort_keys() { + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); + let mut launch_env = env(&[(ACP_KEY, "x"), (GOOSE_KEY, "y")]); + launch.apply(&mut launch_env); + assert!( + launch_env + .keys() + .all(|k| !effort_suppress_keys().contains(&k.as_str())), + "no effort key remains when the projection has no value" + ); +} + +#[test] +fn buzz_agent_generic_column_does_not_leak_acp_sentinel() { + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high")]); + r.effort_level = Some("medium".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!(launch.value.as_deref(), Some("medium")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY), + None, + "ACP sentinel stripped for buzz-agent" + ); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY).map(String::as_str), + Some("medium") + ); +} + +// -------------------------------------------------------------------------- +// External review fix #2 — unknown/custom runtimes restore main's pass-through +// -------------------------------------------------------------------------- + +#[test] +fn unknown_runtime_does_not_suppress_user_effort_env() { + // Regression: a custom wrapper with GOOSE_THINKING_EFFORT=high in record env + // must reach the child unchanged. For unknown runtimes `suppress` is exactly + // `[BUZZ_ACP_EFFORT_LEVEL]` — no foreign effort key is stripped. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "high"), ("UNRELATED", "keep")]); + let launch = project_record_only(&r, None); + assert_eq!( + launch.suppress, + vec![ACP_KEY], + "unknown runtime suppresses only its own sentinel, never a foreign key" + ); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(GOOSE_KEY).map(String::as_str), + Some("high"), + "custom-wrapper effort key survives an unknown-runtime launch" + ); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} + +#[test] +fn unknown_runtime_keeps_user_acp_sentinel_when_no_column() { + // Custom adapter with hand-set sentinel and no column: sentinel carries through. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "hand-set sentinel survives on an unknown runtime with no column" + ); +} + +#[test] +fn unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column() { + // Carl P2 (no-column): a hand-set mixed-case sentinel on a custom runtime + // must survive AND be re-emitted under the canonical spelling. Leaving the + // lowercase variant would hand the child a value the snapshot read misses. + let mut r = record(); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "mixed-case pass-through sentinel re-emitted under canonical key" + ); + assert_eq!( + launch_env.get("buzz_acp_effort_level"), + None, + "mixed-case spelling is collapsed away" + ); +} + +#[test] +fn unknown_runtime_no_column_multi_variant_preserves_windows_effective_value() { + // Pass-3 IMPORTANT (Thufir): both case spellings of the sentinel survive the + // case-sensitive layer merge. Rust `Command` writes in `BTreeMap` iteration + // order into a case-folded env map (last set wins); canonical `B` sorts before + // lowercase `b`, so the lowercase `low` is written last and wins. The carry + // selects the LAST case-insensitive match, matching that. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high"), ("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "carry preserves the last-in-iteration-order value the Windows child receives" + ); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + // Mutation: reverting the carry to exact-first `get_ci` selects `high`. +} + +#[test] +fn unknown_runtime_column_wins_over_mixed_case_sentinel() { + // Carl P2 (with-column): canonical column plus mixed-case sentinel. Column + // wins; the projection strips ALL case variants of the sentinel before emit. + let mut r = record(); + r.effort_level = Some("high".into()); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("high"), + "column wins, emitted under canonical sentinel key" + ); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + // Mutation: empty suppress set leaves `buzz_acp_effort_level=low` in child. +} + +#[test] +fn unknown_runtime_column_still_emits_under_acp_sentinel() { + // The retained compatibility emission: an unknown runtime with a canonical + // column emits it raw under the ACP sentinel (matches the PR-body decision). + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// External review fix #3 — destination-vocabulary validation at projection +// -------------------------------------------------------------------------- + +#[test] +fn goose_off_column_skips_for_buzz_agent_destination() { + // Regression: canonical column `off` is valid Goose but NOT a buzz-agent + // effort. Switching a record with effort_level=off to buzz-agent must NOT + // emit BUZZ_AGENT_THINKING_EFFORT=off — parse_thinking_effort rejects it and + // the child exits 2. Invalid → skip as absent → no key emitted. + let mut r = record(); + r.effort_level = Some("off".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value, None, + "foreign canonical `off` skipped for buzz-agent's vocabulary" + ); + + let mut launch_env = BTreeMap::new(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY), + None, + "no effort key emitted when the value is outside the destination vocabulary" + ); +} + +#[test] +fn buzz_agent_minimal_column_skips_for_goose_destination() { + // The reverse: `minimal` is a valid buzz-agent effort but invalid Goose, so + // switching to Goose skips it as absent (already covered by normalization, + // pinned here as the symmetric vocabulary case). + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value, None); +} + +#[test] +fn buzz_agent_accepts_its_own_distinct_efforts() { + // buzz-agent keeps xhigh and max distinct (no Goose-style xhigh→max + // collapse): both are valid and pass through unchanged. + for v in ["xhigh", "max", "none", "minimal"] { + let mut r = record(); + r.effort_level = Some(v.into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value.as_deref(), + Some(v), + "buzz-agent accepts `{v}` verbatim (no alias collapse)" + ); + } +} + +// -------------------------------------------------------------------------- +// External review fix #4 — case-insensitive suppression / lookup +// -------------------------------------------------------------------------- + +#[test] +fn mixed_case_native_key_is_read_and_wins() { + // Windows Command case-folds env names, so `goose_thinking_effort` is the + // same variable as the canonical form. The tier reader must find it. + let mut r = record(); + r.env_vars = env(&[("goose_thinking_effort", "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "mixed-case record-native key is read and outranks the column" + ); +} + +#[test] +fn duplicate_case_native_variants_resolve_to_windows_effective_value() { + // Carl P2 (r8): both case spellings of a known native key in the record env. + // Rust `Command` writes in `BTreeMap` order into a case-folded map; canonical + // `GOOSE_THINKING_EFFORT` sorts before lowercase, so the lowercase `high` is + // written last and wins. `get_ci` must select the LAST match, not exact-case. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low"), ("goose_thinking_effort", "high")]); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "known-runtime native lookup selects the last case variant Windows Command sets" + ); + // Mutation: reverting `get_ci` to exact-first selects `low`. +} + +#[test] +fn apply_strips_mixed_case_effort_keys() { + // A hand-set mixed-case foreign effort key must be swept, not left to + // shadow the projected value once Windows case-folds it at spawn. + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + + let mut launch_env = env(&[ + ("Goose_Thinking_Effort", "stale"), + ("buzz_acp_effort_level", "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + + // Only the canonical projected key remains; both mixed-case foreign keys + // are gone. + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get("Goose_Thinking_Effort"), None); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} + +// Command-boundary strip and production-sequence tests are in the sibling module. +#[cfg(test)] +#[path = "effort_cmd_tests.rs"] +mod cmd_tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72f..9ac2e5bc10f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -1,6 +1,7 @@ mod buzz_agent; mod claude; mod codex; +pub(crate) mod effort; mod goose; pub(crate) mod reader; mod schema_walker; @@ -8,6 +9,25 @@ pub(crate) mod types; pub(crate) use types::*; +/// The legacy effort env key written by pre-migration saves. +/// +/// Harnesses whose native `thinking_env_var` differs from this constant +/// (currently: Goose uses `GOOSE_THINKING_EFFORT`) need the alias resolver in +/// [`effort`] to translate old saves. buzz-agent's native key equals this +/// constant, so no aliasing applies there. +pub(crate) const LEGACY_THINKING_EFFORT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; + +/// Return all known native thinking-effort env keys across all runtimes. +/// +/// Derived from `KNOWN_ACP_RUNTIMES::thinking_env_var` so that adding a new +/// runtime automatically participates in foreign-key suppression without a +/// separate constant to update. +pub(crate) fn all_known_effort_keys() -> impl Iterator { + crate::managed_agents::discovery::KNOWN_ACP_RUNTIMES + .iter() + .filter_map(|rt| rt.thinking_env_var) +} + /// Read the goose harness config file (`~/.config/goose/config.yaml`). /// /// Used by readiness evaluation to silence requirements that are already 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 93827635e90..84eec8db33a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -1,7 +1,10 @@ +use crate::managed_agents::discovery::EffortNormalization; use crate::managed_agents::discovery::KnownAcpRuntime; use crate::managed_agents::types::ManagedAgentRecord; +use super::effort::effort_tier_alias; use super::types::*; +use super::LEGACY_THINKING_EFFORT_KEY; /// Build the full config surface for an agent, merging all tiers. /// @@ -40,6 +43,8 @@ pub(crate) fn read_config_surface( let provider_env_var = runtime_meta.and_then(|m| m.provider_env_var); let provider_locked = runtime_meta.is_some_and(|m| m.provider_locked); let thinking_env_var = runtime_meta.and_then(|m| m.thinking_env_var); + let effort_norm = runtime_meta.and_then(|m| m.effort_normalization); + let effort_accepted = runtime_meta.and_then(|m| m.effort_accepted_values); let supports_acp_native = runtime_meta.is_some_and(|m| m.supports_acp_native_config); let required_fields: &[&str] = runtime_meta .map(|m| m.required_normalized_fields) @@ -93,6 +98,8 @@ pub(crate) fn read_config_surface( &acp_effort, effort_option.map(|o| o.config_id.as_str()), thinking_env_var, + effort_norm, + effort_accepted, is_pre_spawn, tiers, ), @@ -126,7 +133,7 @@ pub(crate) fn read_config_surface( .collect(); // Collect the env var keys already covered by normalized fields. - let normalized_env_keys: Vec<&str> = [ + let mut normalized_env_keys: Vec<&str> = [ model_env_var, provider_env_var, thinking_env_var, @@ -138,10 +145,40 @@ pub(crate) fn read_config_surface( .flatten() .collect(); - // Tier 2a: remaining env vars not covered by normalized fields. + // Hide the legacy effort key from advanced only when it actually wins the + // record tier: native and canonical column are absent/invalid, then legacy + // normalizes. Otherwise `build_thinking_field` represents another winner + // and the legacy key stays editable in Advanced. + let record_legacy_consumed = thinking_env_var + .zip(effort_norm) + .is_some_and(|(native, norm)| { + native != LEGACY_THINKING_EFFORT_KEY + && super::effort::get_ci(&record.env_vars, native) + .and_then(|v| norm.normalize_str(v)) + .is_none() + && record + .effort_level + .as_deref() + .and_then(|v| norm.normalize_str(v)) + .is_none() + && super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY) + .and_then(|v| norm.normalize_str(v)) + .is_some() + }); + if record_legacy_consumed { + normalized_env_keys.push(LEGACY_THINKING_EFFORT_KEY); + } + + // Tier 2a: remaining env vars not covered by normalized fields. Matching is + // ASCII-case-insensitive so a mixed-case managed key (e.g. Windows + // `goose_thinking_effort`) the launch projection already consumed is hidden + // from Advanced rather than shown as a spurious editable extra. let mut advanced = advanced; for (k, v) in &record.env_vars { - if normalized_env_keys.contains(&k.as_str()) { + if normalized_env_keys + .iter() + .any(|nk| nk.eq_ignore_ascii_case(k)) + { continue; } if file_config.extra.contains_key(k) { @@ -542,40 +579,92 @@ fn build_thinking_field( acp_effort: &Option, effort_config_id: Option<&str>, thinking_env_var: Option<&str>, + effort_norm: Option<&'static EffortNormalization>, + effort_accepted: Option<&'static [&'static str]>, is_pre_spawn: bool, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: - // record env > record.effort_level (canonical Buzz-persisted) > ACP > - // persona env > global env > definition env > config file. + // Tier ordering (mirrors the launch projection in `config_bridge::effort`, + // plus the two reader-only tiers the projection has no input for — live ACP + // and the on-disk config file): + // record native > canonical column > record legacy > ACP > + // persona > global > definition > config file. // - // `record.effort_level` is the B5 canonical value: the effort a spawn will - // actually apply at next session start (via `apply_effort_env`). Sitting it - // above ACP means the panel shows the *configured* value the agent will - // launch with rather than a stale live-session reading — the record can't - // be masked by, nor mask, the running value silently. - 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]); + // Every candidate is normalized through the runtime's declared contract + // (`effort_norm`) before validity, precedence, override tracking, and the B + // same-value collapse — the SAME normalizer the launch projection applies — + // so the panel and the next spawn resolve one effective value AND authority. + // For contract runtimes an invalid value (e.g. Goose `minimal`) normalizes + // to `None` and is skipped as absent so a lower tier can win; aliases + // (`none`→`off`, `xhigh`→`max`, case-fold) canonicalize. Contract-less + // runtimes (buzz-agent, Claude/Codex column) pass raw. + let norm = |raw: &str| -> Option { + super::effort::normalize_effort(effort_norm, effort_accepted, raw) + }; - let canonical_effort = record.effort_level.as_deref(); + // Record tiers, split exactly as the projection resolves them: native env + // strictly above the canonical column, legacy env strictly below it. + let rec_native = thinking_env_var + .and_then(|k| super::effort::get_ci(&record.env_vars, k)) + .and_then(|v| norm(v)); + let column = record.effort_level.as_deref().and_then(&norm); + let rec_legacy = thinking_env_var + .filter(|k| *k != LEGACY_THINKING_EFFORT_KEY) + .and_then(|_| super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY)) + .and_then(|v| norm(v)); + + // Inherited env tiers: persona resolves native-then-legacy; global and + // definition are native-only (legacy alias excluded), matching the launch + // projection's per-tier alias policy. + let pers = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.persona_env, k, norm, true)); + let glob = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.global_env, k, norm, false)); + let def = + thinking_env_var.and_then(|k| effort_tier_alias(&tiers.definition_env, k, norm, false)); + let file = file_effort.as_deref().and_then(&norm); + + // Live ACP value: normalized through the runtime CONTRACT only, never the + // persisted `effort_accepted` vocabulary. The ACP running value comes from + // the session's own config-option namespace (e.g. buzz-agent reports + // `default` for its live thinking-level option) — it is a descriptive + // "currently running" fact, never emitted to a spawn, so the + // destination-vocabulary gate that guards the writable tiers must not skip + // it. Goose still canonicalizes (its ACP option values ARE effort values); + // contract-less runtimes pass raw. The matched `config_id` is preserved for + // `write_via` regardless of value validity. + let acp_norm = acp_effort + .as_deref() + .and_then(|v| super::effort::normalize_effort(effort_norm, None, v)); + + // B same-value collapse: when NO record-level authority exists and the live + // ACP value exactly equals what inheritance would already resolve to, drop + // ACP so the panel shows the true baseline origin ("Global default") rather + // than a spurious "Runtime override (this session only)" — the session is + // almost certainly echoing what spawn injected. When a record tier is + // present it wins over ACP anyway, so ACP stays only for override tracking. + let record_present = rec_native.is_some() || column.is_some() || rec_legacy.is_some(); + let baseline_first = [ + pers.as_deref(), + glob.as_deref(), + def.as_deref(), + file.as_deref(), + ] + .into_iter() + .flatten() + .next(); + let acp_for_list = match (record_present, acp_norm.as_deref(), baseline_first) { + (false, Some(a), Some(b)) if a == b => None, + _ => acp_norm.as_deref(), + }; let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ - (rec_env, ConfigOrigin::BuzzExplicit), - (canonical_effort, 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), + (rec_native.as_deref(), ConfigOrigin::BuzzExplicit), + (column.as_deref(), ConfigOrigin::BuzzExplicit), + (rec_legacy.as_deref(), ConfigOrigin::BuzzExplicit), + (acp_for_list, ConfigOrigin::AcpConfigOption), + (pers.as_deref(), ConfigOrigin::PersonaDefault), + (glob.as_deref(), ConfigOrigin::GlobalDefault), + (def.as_deref(), ConfigOrigin::HarnessDefault), + (file.as_deref(), ConfigOrigin::ConfigFile), ]; let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; @@ -746,11 +835,20 @@ fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Optio /// config id (Claude Code uses `id="effort"`). Selecting by category — not by /// a hardcoded id — is what lets the running value, the write config id, and /// the picker options all derive from one entry. +/// +/// `thought_level` is preferred; the legacy invented category `effort` is a +/// fallback for old test fixtures and pre-canonical adapters. The fallback +/// fires only when `thought_level` is entirely absent — an advertised-but-unset +/// `thought_level` entry is still returned (its `current_value` is `None`), so +/// the reader never flips write-routing to the legacy `effort` config id. fn find_effort_option(cache: &SessionConfigCache) -> Option<&AcpConfigOptionEntry> { - cache - .config_options - .iter() - .find(|o| o.category.as_deref() == Some("thought_level")) + let by_category = |category: &str| { + cache + .config_options + .iter() + .find(|o| o.category.as_deref() == Some(category)) + }; + by_category("thought_level").or_else(|| by_category("effort")) } fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool { 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 5fe86e9cf8d..34b4f1496f5 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 @@ -1,5 +1,5 @@ //! Unit tests for `config_bridge/reader.rs` (kept in a sibling file so -//! `reader.rs` stays under the 1000-line budget; `#[path]`-included from +//! `reader.rs` stays under the 1500-line budget; `#[path]`-included from //! there). use std::{collections::BTreeMap, path::Path, sync::Mutex}; @@ -28,7 +28,7 @@ fn with_goose_path_root(value: Option<&str>, body: impl FnOnce() -> T) -> T { } fn test_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -54,17 +54,21 @@ fn test_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::discovery::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, 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, - } + }; + &RUNTIME } fn test_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "test".to_string(), name: "Test Agent".to_string(), persona_id: None, @@ -646,6 +650,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { config_file_format: None, supports_acp_native_config: false, thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, + effort_accepted_values: None, 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"), @@ -957,3 +963,6 @@ fn numeric_max_tokens_inherits_from_global_env() { // ── Extended tests (split file to respect line-count ratchet) ──────────────── #[path = "reader_tests_ext.rs"] mod ext; + +#[path = "reader_tests_ext2.rs"] +mod ext2; 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 index f86793f91a1..fc18a51c622 100644 --- 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 @@ -1,5 +1,5 @@ //! Additional tests for `config_bridge/reader.rs` — split out to keep -//! `reader_tests.rs` under the 1000-line file-size ratchet. +//! `reader_tests.rs` under the 1500-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. @@ -518,3 +518,460 @@ fn claude_default_config_dir_reports_static_settings_path() { .as_deref() .is_some_and(|p| !p.starts_with('~'))); } + +// ── Goose-contract reader normalization + reader/projection parity ──────────── +// +// The reader (`build_thinking_field`) and the launch projection +// (`effort_launch_projection`) must resolve one effective value AND one +// authority for every record/inherited input, or the config panel displays a +// different effort than the next spawn launches. `test_runtime()` is Goose with +// `effort_normalization = GOOSE_EFFORT_NORMALIZATION`, so these exercise the +// normalization gate, alias canonicalization, invalid-value skip/fallthrough, +// and the decisive mixed-authority case — the phase-1 behavior block, not just +// fixture metadata. + +use crate::managed_agents::config_bridge::effort::effort_launch_projection; + +/// Drive the projection from the SAME record + global env the reader sees, so +/// the two resolvers are compared on identical inputs. Persona/definition tiers +/// use distinct input shapes across the two layers and are covered separately; +/// record-native/column/legacy and global are expressible identically here, +/// which is exactly where the authority-order contract is decisive. +fn projection_value( + record: &ManagedAgentRecord, + global_env: &BTreeMap, +) -> Option { + effort_launch_projection( + record, + Some(test_runtime()), + &[], + None, + global_env, + None, + &BTreeMap::new(), + ) + .value +} + +/// Goose invalid record-native value (`minimal` — not in the Goose contract) +/// skips as absent so a valid lower tier wins, IDENTICALLY in reader and +/// projection. This is Thufir's named regression: a raw winner in the panel +/// while the launch skips it. +#[test] +fn goose_invalid_record_native_skips_to_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "minimal".to_string()); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("valid column must win when native is invalid"); + // Reader: invalid native skipped, column wins. + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Projection agrees on value. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Goose alias canonicalization: `xhigh` → `max` in BOTH resolvers (record +/// native), `none` → `off` (column). +#[test] +fn goose_aliases_canonicalize_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "xhigh".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); + + let mut record2 = test_record(); + record2.effort_level = Some("none".to_string()); + let surface2 = read_config_surface(&record2, Some(runtime), None, &no_tiers(), None); + assert_eq!( + surface2 + .normalized + .thinking_effort + .unwrap() + .value + .as_deref(), + Some("off") + ); + assert_eq!( + projection_value(&record2, &BTreeMap::new()).as_deref(), + Some("off") + ); +} + +/// The decisive mixed-authority case (Thufir/Paul acceptance pin): a valid +/// record-native value and a DIFFERENT valid column → the native value wins in +/// reader and projection alike. The column is the surfaced override baseline. +#[test] +fn goose_record_native_outranks_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + record.effort_level = Some("low".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Column is the overridden baseline (next distinct tier below native). + assert_eq!(effort.overridden_value.as_deref(), Some("low")); + // Projection resolves the same authority. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Invalid column AND invalid native → both skip; a valid global tier wins in +/// the reader, and the projection (driven from the same global env) agrees. +#[test] +fn goose_invalid_record_tiers_fall_through_to_global_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "bogus".to_string()); + record.effort_level = Some("alsobad".to_string()); + let runtime = test_runtime(); + let mut global = BTreeMap::new(); + global.insert("GOOSE_THINKING_EFFORT".to_string(), "medium".to_string()); + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); + let effort = surface + .normalized + .thinking_effort + .expect("global tier must win when both record tiers are invalid"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::GlobalDefault); + assert_eq!( + projection_value(&record, &global).as_deref(), + Some("medium") + ); +} + +/// Goose legacy alias (`BUZZ_AGENT_THINKING_EFFORT`) is accepted for the record +/// tier below the column, canonicalized, in reader and projection alike. +#[test] +fn goose_record_legacy_alias_below_column_in_reader_and_projection() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "xhigh".to_string(), + ); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("record legacy alias must surface when native and column are absent"); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); +} + +/// B same-value collapse: no record authority, live ACP echoes the inherited +/// global value → the panel shows the inherited origin (GlobalDefault), not a +/// spurious per-session AcpConfigOption override. +#[test] +fn goose_acp_equal_to_global_collapses_to_global_origin() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("medium".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("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!( + effort.origin, + ConfigOrigin::GlobalDefault, + "ACP echoing the inherited value must not masquerade as a session override" + ); +} + +/// B same-value collapse does NOT fire on genuine divergence: live ACP differs +/// from the inherited baseline → ACP wins as the per-session override, global +/// is the surfaced baseline. +#[test] +fn goose_acp_diverging_from_global_wins_as_override() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".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("GOOSE_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("low")); + assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption); + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +/// Invalid live ACP value is skipped as absent; a valid record tier wins and +/// no phantom ACP override is surfaced. +#[test] +fn goose_invalid_acp_skips_and_record_wins() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("garbage".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 surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +// ── Consumed-legacy Advanced suppression (F2) ──────────────────────────────── +// +// When the record's native effort key is absent/invalid and the legacy key +// (`BUZZ_AGENT_THINKING_EFFORT`) supplies the normalized record effort, the +// legacy key must NOT also re-appear as a generic Advanced field — one +// persisted fact must not surface through two controls. Invalid/unconsumed +// legacy values stay visible in Advanced. + +/// Record has valid legacy `BUZZ_AGENT_THINKING_EFFORT=high` and no native +/// `GOOSE_THINKING_EFFORT` → effort surfaces from the legacy alias AND the +/// legacy key must NOT re-appear in Advanced. +#[test] +fn record_consumed_legacy_effort_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("valid legacy value must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "consumed legacy effort key must not double-emit in advanced; got {advanced_keys:?}" + ); +} + +/// A valid legacy value shadowed by the canonical column is not consumed, so +/// it remains editable in Advanced rather than silently resurfacing later if +/// the column is cleared. +#[test] +fn record_legacy_effort_shadowed_by_column_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "low".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("canonical column must win over legacy record effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "valid but unconsumed record legacy must remain visible in Advanced; got {advanced_keys:?}" + ); +} + +/// An invalid legacy `BUZZ_AGENT_THINKING_EFFORT` value is unconsumed, so it +/// stays visible in Advanced. +#[test] +fn record_invalid_legacy_effort_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "bogus".to_string(), + ); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "invalid legacy value must not be consumed as effort" + ); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "unconsumed legacy key must stay visible in advanced; got {advanced_keys:?}" + ); +} + +// ── F4: legacy `effort` category fallback in find_effort_option ────────────── +// +// `thought_level` is preferred; the legacy invented category `effort` is a +// fallback for pre-canonical adapters. An advertised-but-unset `thought_level` +// must NOT fall through to a set `effort` (that would route the write to the +// wrong config_id), but a cache that advertises only `effort` must still +// surface a thinking field and write route. + +/// `thought_level` present but unset, `effort` present and set → effort must +/// NOT surface from the live cache (no fallthrough); write routing never picks +/// up the legacy `effort` config id. +#[test] +fn unset_thought_level_does_not_fall_through_to_effort_category() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![ + AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Thinking Effort".to_string()), + current_value: None, // advertised but unset + options: vec![], + }, + AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".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 surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "unset thought_level must not fall through to the legacy effort category" + ); +} + +/// `effort` category present and set, no `thought_level` at all → legacy +/// fallback still surfaces the field and routes the write to the matched +/// `effort` config id. +#[test] +fn effort_category_fallback_used_when_thought_level_absent() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".to_string()), + current_value: Some("high".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 surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("legacy effort category must surface when thought_level is absent"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert!( + matches!( + &effort.write_via, + ConfigWriteMechanism::AcpSetConfigOption { config_id } + if config_id == "effort" + ), + "write route must use the legacy effort config_id when it is the only category; got {:?}", + effort.write_via + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs new file mode 100644 index 00000000000..0c5aa69c407 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs @@ -0,0 +1,69 @@ +//! Additional tests for `config_bridge/reader.rs` — split out to keep +//! `reader_tests_ext.rs` under the 1000-line file-size ratchet. +//! +//! Included as `mod ext2` inside `reader_tests.rs`, so `use super::*` gives +//! access to all helpers and types from that module. + +use super::*; + +// ── Fix (external review #4): reader resolves record effort keys ───────────── +// case-insensitively, matching the launch projection. +// +// Windows `Command` case-folds env names, so a hand-set `goose_thinking_effort` +// is the same variable as its canonical form. The reader must resolve it as the +// record-native effort winner AND hide it from Advanced, or the panel disagrees +// with the child the launch projection already consumed the key for. + +/// Mixed-case native record key `goose_thinking_effort=high` wins the record +/// tier and is hidden from Advanced (not shown as a spurious editable extra). +#[test] +fn record_mixed_case_native_effort_wins_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("goose_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case native key must surface as the record effort winner"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"goose_thinking_effort"), + "consumed mixed-case native effort key must not appear in advanced; got {advanced_keys:?}" + ); +} + +/// Mixed-case legacy record key `buzz_agent_thinking_effort=high` (no native, +/// no column) supplies the record effort AND is hidden from Advanced. +#[test] +fn record_mixed_case_legacy_effort_consumed_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("buzz_agent_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case legacy key must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"buzz_agent_thinking_effort"), + "consumed mixed-case legacy effort key must not appear in advanced; got {advanced_keys:?}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/definition_validation.rs b/desktop/src-tauri/src/managed_agents/definition_validation.rs index e063eb85cd8..17e75d7bdac 100644 --- a/desktop/src-tauri/src/managed_agents/definition_validation.rs +++ b/desktop/src-tauri/src/managed_agents/definition_validation.rs @@ -10,6 +10,8 @@ use std::sync::LazyLock; const MAX_DISPLAY_NAME_CHARS: usize = 128; const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024; +/// Cap for the optional public agent description. +pub(crate) const MAX_AGENT_DESCRIPTION_CHARS: usize = 280; const EMOJI_VARIATION_SELECTOR: char = '\u{FE0F}'; const ZERO_WIDTH_JOINER: char = '\u{200D}'; @@ -41,6 +43,23 @@ pub(crate) fn validate_agent_definition_text( validate_visible_text(system_prompt, "Agent instructions", true) } +/// Validate an optional public agent description: max 280 characters and the +/// same visible-text policy as the other definition fields (invisible, bidi, +/// and control characters are rejected, not stripped). `None` and the empty +/// string are both valid — the description is optional. +pub(crate) fn validate_agent_description_text(description: Option<&str>) -> Result<(), String> { + let Some(description) = description else { + return Ok(()); + }; + let description_chars = description.chars().count(); + if description_chars > MAX_AGENT_DESCRIPTION_CHARS { + return Err(format!( + "Description is too long ({description_chars} characters, max {MAX_AGENT_DESCRIPTION_CHARS})" + )); + } + validate_visible_text(description, "Description", false) +} + /// Validate the human-reviewed definition text carried by a managed agent. /// /// Definition-linked agents resolve their executable prompt through the @@ -243,6 +262,37 @@ mod tests { assert!(validate_agent_definition_text("Reviewer", &"a".repeat(64 * 1024 + 1)).is_err()); } + #[test] + fn description_accepts_none_empty_and_plain_text() { + assert!(validate_agent_description_text(None).is_ok()); + assert!(validate_agent_description_text(Some("")).is_ok()); + assert!(validate_agent_description_text(Some("Buttercup, a software engineer 🐝")).is_ok()); + assert!( + validate_agent_description_text(Some(&"a".repeat(MAX_AGENT_DESCRIPTION_CHARS))).is_ok() + ); + } + + #[test] + fn description_rejects_over_280_chars() { + assert!(validate_agent_description_text(Some( + &"a".repeat(MAX_AGENT_DESCRIPTION_CHARS + 1) + )) + .is_err()); + } + + #[test] + fn description_rejects_invisible_bidi_and_control_characters() { + for character in ['\u{200B}', '\u{202E}', '\u{2066}', '\0', '\r', '\u{0007}'] { + for description in [ + format!("A helpful{character}agent"), + format!("{character}A helpful agent"), + format!("A helpful agent{character}"), + ] { + assert!(validate_agent_description_text(Some(&description)).is_err()); + } + } + } + #[test] fn definition_less_managed_agent_validates_its_own_name_and_prompt() { assert!(validate_managed_agent_definition_text( diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 1ee7e6e5562..84f88e406b5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -15,6 +15,8 @@ mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +mod catalog; +pub(crate) use catalog::KNOWN_ACP_RUNTIMES; pub use login_shell::{find_nvm_default_bin, login_shell_path}; pub(crate) use login_shell::{find_via_login_shell, refresh_login_shell_path}; #[cfg(test)] @@ -26,7 +28,10 @@ pub(crate) use presets::{ preset_harness_ids, }; use presets::{preset_catalog_entry, PRESET_HARNESSES}; +pub(crate) use runtime_metadata::EffortNormalization; pub(crate) use runtime_metadata::KnownAcpRuntime; +#[cfg(test)] +pub(crate) use runtime_metadata::GOOSE_EFFORT_NORMALIZATION; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; @@ -83,144 +88,6 @@ fn common_binary_paths() -> &'static [PathBuf] { }) } -const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ - KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: GOOSE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("goose"), - 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: &[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: "", - cli_install_hint: "Buzz talks to Goose through the Goose CLI.", - adapter_install_hint: "", - skill_dir: Some(".goose/skills"), - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[("GOOSE_MODE", "auto")], - 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, - }, - KnownAcpRuntime { - id: "claude", - label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], - avatar_url: CLAUDE_CODE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("claude"), - cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - 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", - cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", - adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", - skill_dir: Some(".claude/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: true, - default_env: &[], - config_file_path: Some("~/.claude/settings.json"), - config_file_format: Some("json"), - supports_acp_native_config: false, - 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"]), - }, - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: &["codex-acp"], - aliases: &[], - avatar_url: CODEX_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: false, - underlying_cli: Some("codex"), - cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - 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", - cli_install_hint: "Buzz talks to Codex through the Codex CLI.", - adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", - skill_dir: Some(".codex/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.codex/config.toml"), - config_file_format: Some("toml"), - supports_acp_native_config: false, - 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. - auth_probe_args: Some(&["codex", "login", "status"]), - }, - KnownAcpRuntime { - id: "buzz-agent", - label: "Buzz Agent", - commands: &["buzz-agent"], - aliases: &[], - avatar_url: BUZZ_AGENT_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: true, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "https://github.com/block/buzz", - adapter_install_instructions_url: "https://github.com/block/buzz", - cli_install_hint: "Ships with the Buzz desktop app.", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: true, - model_env_var: Some("BUZZ_AGENT_MODEL"), - provider_env_var: Some("BUZZ_AGENT_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: None, - config_file_format: None, - supports_acp_native_config: false, - 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, - }, -]; - /// Skill discovery directories declared by known runtimes. pub(crate) fn known_skill_dirs() -> impl Iterator { KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) @@ -375,7 +242,11 @@ pub fn effective_agent_command( } mod overrides; -pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; +pub use overrides::remove_record_effort_aliases; +pub use overrides::{ + apply_agent_command_update, apply_env_vars_then_effort_transition, + create_time_agent_command_override, +}; /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. @@ -1168,6 +1039,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) - 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), + effort_canonical_values: runtime + .effort_normalization + .map(|norm| norm.canonical.iter().map(|s| s.to_string()).collect()), 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), @@ -1308,6 +1182,7 @@ pub fn discover_acp_runtimes_from( model_env_var: None, provider_env_var: None, thinking_env_var: None, + effort_canonical_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs new file mode 100644 index 00000000000..fecf792f214 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -0,0 +1,156 @@ +//! The known-ACP-runtime catalog. Extracted from `discovery.rs` as pure data +//! (mirroring `presets::PRESET_HARNESSES`) so the module stays under the +//! file-size ratchet. The `windows_install_command!` macro is in textual scope +//! here because this module is declared after `#[macro_use] mod windows_install` +//! in the parent. + +use super::runtime_metadata::{ + KnownAcpRuntime, BUZZ_AGENT_EFFORT_VALUES, GOOSE_EFFORT_NORMALIZATION, +}; +use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; + +pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ + KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: GOOSE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("goose"), + 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: &[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: "", + cli_install_hint: "Buzz talks to Goose through the Goose CLI.", + adapter_install_hint: "", + skill_dir: Some(".goose/skills"), + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[("GOOSE_MODE", "auto")], + 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"), + effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, // goose: validated via effort_normalization + 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, + }, + KnownAcpRuntime { + id: "claude", + label: "Claude Code", + commands: &["claude-agent-acp", "claude-code-acp"], + aliases: &["claude-code", "claudecode"], + avatar_url: CLAUDE_CODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("claude"), + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + 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", + cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", + adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", + skill_dir: Some(".claude/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.claude/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // claude: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // claude: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL + 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"]), + }, + KnownAcpRuntime { + id: "codex", + label: "Codex", + commands: &["codex-acp"], + aliases: &[], + avatar_url: CODEX_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: false, + underlying_cli: Some("codex"), + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + 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", + cli_install_hint: "Buzz talks to Codex through the Codex CLI.", + adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", + skill_dir: Some(".codex/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.codex/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // codex: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // codex: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL + 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. + auth_probe_args: Some(&["codex", "login", "status"]), + }, + KnownAcpRuntime { + id: "buzz-agent", + label: "Buzz Agent", + commands: &["buzz-agent"], + aliases: &[], + avatar_url: BUZZ_AGENT_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: true, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", + cli_install_hint: "Ships with the Buzz desktop app.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: Some("BUZZ_AGENT_MODEL"), + provider_env_var: Some("BUZZ_AGENT_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, // buzz-agent: per-model catalog; see getProviderEffortConfig() in TS + effort_accepted_values: Some(BUZZ_AGENT_EFFORT_VALUES), // buzz-agent: parse_thinking_effort's accepted set + 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, + }, +]; diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdda..fa339a03b70 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -83,27 +83,85 @@ pub fn update_time_agent_command_override( /// Apply an explicit `agent_command` edit to `record`: persist the override /// pin decided by [`update_time_agent_command_override`], and on the inherit /// sentinel (empty/whitespace command) also clear the materialized -/// `record.runtime` so the resolution ladder falls through to the live -/// definition immediately instead of silently keeping the stale instance copy. +/// `record.runtime` AND the persisted per-instance effort column so the +/// resolution ladder falls through to the live definition immediately instead +/// of silently keeping the stale instance copy. /// -/// The runtime clear is guarded on a live persona link: for a definition-less -/// record the materialized runtime is the only harness source left after the -/// override clear, so a stray empty `agent_command` from a non-dialog caller -/// must not change what the agent runs. +/// The clears are guarded on a live persona link: for a definition-less record +/// the materialized runtime is the only harness source left after the override +/// clear, so a stray empty `agent_command` from a non-dialog caller must not +/// change what the agent runs. +/// +/// Returns `true` when the pin→inherit transition fired. The caller MUST then, +/// AFTER applying any caller-supplied `env_vars`, strip the record effort env +/// aliases via [`remove_record_effort_aliases`] — clearing them here would be +/// undone by a same-request `env_vars` replacement (see the update boundary in +/// `agent_models_update.rs`), so the alias strip is an update-boundary +/// invariant, not a helper-local one. +#[must_use] pub fn apply_agent_command_update( record: &mut crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], agent_command: &str, harness_override: bool, -) { +) -> bool { record.agent_command_override = update_time_agent_command_override( record.persona_id.as_deref(), personas, Some(agent_command), harness_override, ); - if agent_command.trim().is_empty() && record.persona_id.is_some() { + let inherit_transition = agent_command.trim().is_empty() && record.persona_id.is_some(); + if inherit_transition { record.runtime = None; + // The generic canonical effort column is a per-instance pin; on the + // pin→inherit transition it is dropped so the agent inherits the + // persona/global effort. The record effort ENV aliases are stripped by + // the caller after `env_vars` is applied (see the doc above). + record.effort_level = None; + } + inherit_transition +} + +/// Strip every record-level thinking-effort env alias — all known native keys +/// plus the legacy `BUZZ_AGENT_THINKING_EFFORT` alias — from `env_vars`. +/// +/// Called at the `update_managed_agent` boundary on the pin→inherit transition, +/// AFTER caller-supplied `env_vars` have been applied, so the cleared aliases +/// cannot be reintroduced by the same request. Together with the column clear +/// in [`apply_agent_command_update`], this makes the instance drop its entire +/// per-instance effort override atomically at Save. +pub fn remove_record_effort_aliases(env_vars: &mut std::collections::BTreeMap) { + let suppress = crate::managed_agents::config_bridge::effort::effort_suppress_keys(); + env_vars.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); +} + +/// Apply a same-request `env_vars` replacement and then enforce the pin→inherit +/// effort-alias strip, in that exact order. +/// +/// This is the ordering invariant Thufir's plan-of-record pins: the effort +/// column is cleared eagerly inside [`apply_agent_command_update`], but a stale +/// effort env alias in a caller-supplied `env_vars` map submitted in the SAME +/// request would otherwise survive the transition. Applying `env_vars` first, +/// then stripping the aliases only on the transition, guarantees the instance +/// cannot re-pin effort through the generic env channel while inheriting its +/// harness. `env_vars = None` leaves the record's existing env untouched; +/// validation of the supplied map is the caller's responsibility (it runs +/// before this seam at the update boundary). +pub fn apply_env_vars_then_effort_transition( + record: &mut crate::managed_agents::types::ManagedAgentRecord, + env_vars: Option>, + inherit_transition: bool, +) { + if let Some(env_vars) = env_vars { + record.env_vars = env_vars; + } + if inherit_transition { + remove_record_effort_aliases(&mut record.env_vars); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index fd853094515..438cb5eb863 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -67,6 +67,7 @@ pub(super) fn preset_catalog_entry( model_env_var: None, provider_env_var: None, thinking_env_var: None, + effort_canonical_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -90,6 +91,15 @@ pub(super) fn preset_catalog_entry( } pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ + PresetHarness { + id: "pi", + label: "Pi", + command: "pi-acp", + args: &[], + install_instructions_url: "https://github.com/svkozak/pi-acp", + install_hint: "Buzz talks to Pi through the pi-acp adapter. Install Pi with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, then install the adapter with `npm install -g pi-acp`.", + underlying_cli: Some("pi"), + }, PresetHarness { id: "devin", label: "Devin", @@ -347,6 +357,48 @@ mod tests { assert_eq!(entry.source, HarnessSource::Preset); } + #[test] + fn pi_preset_uses_zero_arg_adapter_and_reports_missing_component() { + let preset = PRESET_HARNESSES + .iter() + .find(|preset| preset.id == "pi") + .expect("Pi preset should be present"); + + assert_eq!(preset.label, "Pi"); + assert_eq!(preset.command, "pi-acp"); + assert!(preset.args.is_empty()); + assert_eq!(preset.underlying_cli, Some("pi")); + + let available = preset_catalog_entry(preset, |command| match command { + "pi-acp" => Some(PathBuf::from("/usr/local/bin/pi-acp")), + "pi" => Some(PathBuf::from("/usr/local/bin/pi")), + _ => None, + }); + assert_eq!(available.availability, AcpAvailabilityStatus::Available); + assert_eq!(available.command.as_deref(), Some("pi-acp")); + assert!(available.default_args.is_empty()); + assert_eq!( + available.underlying_cli_path.as_deref(), + Some("/usr/local/bin/pi") + ); + + let adapter_missing = preset_catalog_entry(preset, |command| { + (command == "pi").then(|| PathBuf::from("/usr/local/bin/pi")) + }); + assert_eq!( + adapter_missing.availability, + AcpAvailabilityStatus::AdapterMissing + ); + assert!(adapter_missing.command.is_none()); + assert!(adapter_missing.default_args.is_empty()); + + let not_installed = preset_catalog_entry(preset, |_| None); + assert_eq!( + not_installed.availability, + AcpAvailabilityStatus::NotInstalled + ); + } + #[test] fn adapter_missing_when_underlying_cli_present() { let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| { 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 34edecdcd9c..b68bc84c23f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -1,3 +1,69 @@ +/// Canonicalization contract for a harness's thinking-effort env var. +/// +/// The single value authority shared by UI choices, the spawn/deploy launch +/// projection, and the reader. All effort candidates (native env, legacy env, +/// ACP tier, file tier) are normalized through `normalize_str` before any +/// validity, precedence, override, or B-equality check. +/// +/// Source for Goose: `crates/goose-provider-types/src/thinking.rs` +/// • `FromStr` (aliases, case-insensitive): `off|disabled|none`, `low`, +/// `medium|med`, `high`, `max|xhigh` +/// • `Display` (canonical): `off`, `low`, `medium`, `high`, `max` +/// • Live ACP emits Display values via `response_builder.rs:326-337`. +pub(crate) struct EffortNormalization { + /// Canonical values in UI display order (drive choices, persistence, ACP comparison). + pub canonical: &'static [&'static str], + /// `(alias, canonical)` pairs, case-insensitive. Only aliases that differ + /// from their canonical form are listed. + pub aliases: &'static [(&'static str, &'static str)], +} + +/// Goose thinking-effort canonicalization contract. +/// +/// Source: `crates/goose-provider-types/src/thinking.rs` at Goose `2db0e31fe`. +/// Canonical Display values: `off`, `low`, `medium`, `high`, `max`. +/// Aliases (case-insensitive): `none|disabled→off`, `med→medium`, `xhigh→max`. +/// `minimal` (Buzz-only) is invalid — skipped as absent at every tier. +pub(crate) static GOOSE_EFFORT_NORMALIZATION: EffortNormalization = EffortNormalization { + canonical: &["off", "low", "medium", "high", "max"], + aliases: &[ + ("none", "off"), + ("disabled", "off"), + ("med", "medium"), + ("xhigh", "max"), + ], +}; + +/// buzz-agent's accepted persisted thinking-effort values — a validation-only +/// contract, NOT a canonicalization one. Unlike Goose, buzz-agent keeps `xhigh` +/// and `max` as *distinct* efforts, so these values are validated (invalid → +/// skip as absent) but never aliased or collapsed. +/// +/// Source of truth: `parse_thinking_effort`, `crates/buzz-agent/src/config.rs` +/// (`none|minimal|low|medium|high|xhigh|max`). A destination-vocabulary check +/// at projection time keeps a foreign canonical (e.g. Goose `off`) from being +/// emitted as `BUZZ_AGENT_THINKING_EFFORT=off`, which the parser rejects at +/// config init (child exits 2). +pub(crate) static BUZZ_AGENT_EFFORT_VALUES: &[&str] = + &["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + +impl EffortNormalization { + /// Normalize `raw` to canonical form. `None` → invalid for this harness; + /// the caller must treat it as absent (skip-as-absent policy). + pub fn normalize_str(&self, raw: &str) -> Option { + let lower = raw.to_lowercase(); + if self.canonical.contains(&lower.as_str()) { + return Some(lower); + } + for &(alias, canon) in self.aliases { + if lower == alias { + return Some(canon.to_string()); + } + } + None + } +} + /// Static capabilities and installation metadata for a known ACP runtime. pub(crate) struct KnownAcpRuntime { pub id: &'static str, @@ -47,6 +113,35 @@ pub(crate) struct KnownAcpRuntime { pub config_file_format: Option<&'static str>, pub supports_acp_native_config: bool, // tier 1a: config/read+write pub thinking_env_var: Option<&'static str>, + /// Canonicalization contract for `thinking_env_var` on this harness. + /// + /// `Some(contract)` — harness uses a finite, static effort vocabulary. + /// All candidates (native env, legacy env, ACP tier, file tier) are + /// normalized through this contract before validity checks, precedence + /// resolution, override tracking, and B-equality comparison. + /// + /// `None` — harness accepts any provider/model-specific value via its own + /// catalog (buzz-agent); see `getProviderEffortConfig()` in TS for that + /// path. Contract-less does NOT mean keyless: buzz-agent still has a native + /// `thinking_env_var`, and Claude/Codex route the canonical through + /// `BUZZ_ACP_EFFORT_LEVEL` for ACP startup even with `thinking_env_var: None`. + /// + /// The single canonical authority shared by UI choices, the launch + /// projection, and the reader. No value-authority logic may live outside + /// this struct for harnesses that declare one. + pub effort_normalization: Option<&'static EffortNormalization>, + /// Accepted persisted effort values for a runtime that has NO + /// canonicalization contract but still constrains its vocabulary + /// (buzz-agent: `parse_thinking_effort`'s accepted set). Used only for + /// destination-vocabulary validation at projection/read time — a candidate + /// outside this set is skipped as absent, so a foreign canonical (e.g. + /// Goose `off`) is never emitted under `thinking_env_var` where the + /// destination parser would reject it and crash the child. + /// + /// `None` means "no validation": Goose validates through + /// `effort_normalization`; Claude/Codex and unknown/custom runtimes accept + /// any string over the `BUZZ_ACP_EFFORT_LEVEL` transport. + pub effort_accepted_values: Option<&'static [&'static str]>, /// Env var for normalizing `max_output_tokens`. `None` when the harness /// does not have a first-class env var for this field (config-file only). pub max_tokens_env_var: Option<&'static str>, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index ff5cfc34725..dc155d82f5b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -2,12 +2,13 @@ use std::path::PathBuf; use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ - apply_agent_command_update, classify_runtime, codex_adapter_availability, - codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, is_login_shell_path_uninit, is_safe_nvm_tag, - managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version, - record_agent_command, refresh_login_shell_path, try_record_agent_command, - BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + apply_agent_command_update, apply_env_vars_then_effort_transition, classify_runtime, + codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, + default_agent_command, effective_agent_command, find_nvm_default_bin, + is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, + parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, + remove_record_effort_aliases, try_record_agent_command, BUZZ_AGENT_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -167,9 +168,9 @@ fn classifies_cli_missing_when_adapter_found_but_cli_absent() { assert_eq!(cmd.as_deref(), Some("codex-acp")); assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); } - fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { + description: None, id: id.to_string(), display_name: id.to_string(), avatar_url: None, @@ -204,14 +205,14 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests. Only the resolution -/// inputs (runtime / persona_id / agent_command_override) vary. +/// Minimal record for `record_agent_command` tests; only resolution inputs vary. fn record_with( runtime: Option<&str>, persona_id: Option<&str>, override_cmd: Option<&str>, ) -> crate::managed_agents::types::ManagedAgentRecord { crate::managed_agents::types::ManagedAgentRecord { + description: None, pubkey: String::new(), name: "r".to_string(), persona_id: persona_id.map(str::to_string), @@ -606,51 +607,9 @@ fn update_time_override_preserves_pin_for_persona_less_agent() { ); } -#[test] -fn apply_agent_command_update_inherit_sentinel_clears_pin_and_runtime() { - // Choosing Inherit on a persona-linked record clears BOTH the explicit - // pin and the materialized runtime, so resolution falls through to the - // live definition immediately — not on the next spawn. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); - - apply_agent_command_update(&mut record, &personas, "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime, None); - assert_eq!(record_agent_command(&record, &personas), "goose"); -} - -#[test] -fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { - // For a record with no persona link the materialized runtime is the only - // harness source left once the pin is cleared — a stray empty - // agent_command must not change what the agent runs. - let mut record = record_with(Some("claude"), None, Some("codex-acp")); - - apply_agent_command_update(&mut record, &[], "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); -} - -#[test] -fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { - // A concrete pick only sets the pin; the materialized runtime is left for - // the next snapshot apply. The pin shadows it in resolution either way. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), None); - - apply_agent_command_update(&mut record, &personas, "codex-acp", true); - - assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &personas), "codex-acp"); -} - // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod effort_clear; mod forced_discovery; mod managed_path_resolution; #[cfg(unix)] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs new file mode 100644 index 00000000000..bdeb1a10802 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs @@ -0,0 +1,188 @@ +//! Backend tests for the pin→inherit effort clear (PR #4625, plan-of-record +//! item 1): the sentinel transition clears the canonical column eagerly and the +//! update boundary strips the record effort env aliases AFTER caller `env_vars` +//! is applied. Split out of `discovery/tests.rs` to hold that file under the +//! desktop file-size ratchet. +//! +//! `use super::*` pulls the parent test module's helpers (`record_with`, +//! `persona_with_runtime`, `record_agent_command`) and its imported command +//! surface (`apply_agent_command_update`, `apply_env_vars_then_effort_transition`, +//! `remove_record_effort_aliases`). + +use super::*; + +#[test] +fn apply_agent_command_update_inherit_sentinel_clears_pin_runtime_and_column() { + // Choosing Inherit on a persona-linked record clears the explicit pin, the + // materialized runtime, AND the per-instance effort column, so resolution + // falls through to the live definition immediately — not on the next spawn. + // The transition flag fires so the caller strips the record effort env + // aliases after `env_vars` is applied. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + + assert!(transition, "the pin→inherit transition must be signalled"); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime, None); + assert_eq!( + record.effort_level, None, + "the effort column must be cleared" + ); + assert_eq!(record_agent_command(&record, &personas), "goose"); +} + +#[test] +fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { + // For a record with no persona link the materialized runtime is the only + // harness source left once the pin is cleared — a stray empty + // agent_command must not change what the agent runs, nor clear its effort. + let mut record = record_with(Some("claude"), None, Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &[], "", false); + + assert!( + !transition, + "a definition-less stray sentinel is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a definition-less record must preserve its effort column" + ); + assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); +} + +#[test] +fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime_and_column() { + // A concrete pick only sets the pin; the materialized runtime and the + // effort column are left intact (no ownership transition). The pin shadows + // the runtime in resolution either way. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + + assert!( + !transition, + "a concrete pin is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a concrete pin must preserve the effort column" + ); + assert_eq!(record_agent_command(&record, &personas), "codex-acp"); +} + +#[test] +fn remove_record_effort_aliases_strips_all_known_and_legacy_keys() { + // The update-boundary alias strip: after `env_vars` is applied on the + // pin→inherit transition, every known native effort key and the legacy + // alias must be removed, while unrelated env survives. This proves the + // second half of the atomic clear that a helper-only column clear cannot. + let mut env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "high"), + ("BUZZ_AGENT_THINKING_EFFORT", "high"), + ("BUZZ_ACP_EFFORT_LEVEL", "high"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + remove_record_effort_aliases(&mut env); + + assert!(!env.contains_key("GOOSE_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_AGENT_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_ACP_EFFORT_LEVEL")); + assert_eq!( + env.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env must survive the effort-alias strip" + ); +} + +#[test] +fn update_boundary_inherit_sentinel_with_alias_bearing_env_vars_strips_after_apply() { + // The update-boundary ORDERING invariant (Thufir pass-3): on the pin→inherit + // transition, a SAME-REQUEST `env_vars` map carrying a stale effort alias + // must NOT survive. `apply_agent_command_update` clears the column eagerly; + // then `apply_env_vars_then_effort_transition` applies the caller env FIRST + // and strips the aliases AFTER — so the alias the request tried to + // reintroduce is gone. A helper-only test cannot prove this order. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + assert!( + transition, + "empty command on a persona-linked record is inherit" + ); + + // The request replaces env_vars with a map that re-pins effort via an alias + // plus an unrelated key. + let request_env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "max"), + ("BUZZ_ACP_EFFORT_LEVEL", "max"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!(record.effort_level, None, "column stays cleared"); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "same-request native alias must not survive the transition" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "same-request ACP sentinel must not survive the transition" + ); + assert_eq!( + record.env_vars.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env from the same request is preserved" + ); +} + +#[test] +fn update_boundary_concrete_pin_preserves_alias_bearing_env_vars() { + // No transition (concrete pin): the caller `env_vars` — including any effort + // alias — is applied verbatim and NOT stripped. Effort env is only cleared + // on the ownership transition, never on an ordinary env edit. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + assert!(!transition, "a concrete pin is not a transition"); + + let request_env: std::collections::BTreeMap = + [("GOOSE_THINKING_EFFORT", "max")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!( + record + .env_vars + .get("GOOSE_THINKING_EFFORT") + .map(String::as_str), + Some("max"), + "without a transition the caller effort env is preserved" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 080a8fbb987..1ed44ace946 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -8,6 +8,7 @@ fn definition( prompt: &str, ) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Definition".to_string(), avatar_url: None, @@ -40,6 +41,7 @@ fn record( ) -> ManagedAgentRecord { use crate::managed_agents::{BackendKind, RespondTo}; ManagedAgentRecord { + description: None, pubkey: "agent-pk".to_string(), name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), 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 f3de11ad242..dc38c3d126f 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -175,6 +175,13 @@ fn reserved_keys_include_remote_lifetime_policy() { } } +#[test] +fn reserved_keys_include_desktop_acp_session_policy() { + assert!(is_reserved_env_key("BUZZ_ACP_SESSION_POLICY")); + let agent = map(&[("BUZZ_ACP_SESSION_POLICY", "thread")]); + 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/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 9d090787c7f..5f39b7b75f2 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -299,6 +299,7 @@ fn default_global_config_serializes_all_fields() { fn bare_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: None, @@ -360,6 +361,7 @@ fn bare_record() -> ManagedAgentRecord { fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Persona".to_string(), avatar_url: None, @@ -622,6 +624,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { record.persona_id = Some("p1".to_string()); let persona = AgentDefinition { + description: None, id: "p1".to_string(), display_name: "Goose persona".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c005e8858b7..a66f9c75ba2 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -8,7 +8,10 @@ pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_ac pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; +mod agent_description; +pub(crate) use agent_description::{effective_agent_description, record_effective_description}; mod backend; +pub(crate) mod bestie_assignment; pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; @@ -35,6 +38,7 @@ pub mod retention; mod runtime; mod runtime_commands; mod runtime_types; +mod session_policy; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; pub(crate) mod storage; @@ -45,18 +49,33 @@ pub(crate) use team_repair::team_persona_key; mod teams; mod types; -// Shared guard for tests that mutate or read process-global PATH. +// Shared lock for tests that call `lock_path_mutex` or `lock_env_mutex`. +// Both helpers delegate here so any two tests using either helper are mutually +// exclusive with each other. Tests in other modules that maintain their own +// independent locks (app_state_tests, agent_config_tests, reader_tests) are +// NOT in this domain and are not covered by this mutex. #[cfg(test)] -static PATH_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); +static PROCESS_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); +// Acquires the shared process-env lock. Call from any test in this module that +// reads, writes, or removes a process-global environment variable (including PATH). #[cfg(test)] pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { - PATH_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) + PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) +} + +// Delegates to the same lock as `lock_path_mutex`. Tests using either helper +// are mutually exclusive with each other; PATH and env-key mutations that go +// through these helpers cannot race. +#[cfg(test)] +pub(crate) fn lock_env_mutex() -> std::sync::MutexGuard<'static, ()> { + PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) } pub use backend::*; pub(crate) use definition_validation::{ - validate_agent_definition_text, validate_managed_agent_definition_text, validate_visible_text, + validate_agent_definition_text, validate_agent_description_text, + validate_managed_agent_definition_text, validate_visible_text, }; pub use discovery::*; pub use env_vars::*; @@ -86,6 +105,10 @@ pub use restore::*; pub use runtime::*; pub use runtime_commands::*; pub use runtime_types::*; +pub(crate) use session_policy::{ + acp_session_policy, apply_app_acp_session_policy_env, insert_acp_session_policy_env, + AcpSessionPolicy, ManagedAgentExperimentState, ACP_SESSION_POLICY_ENV_VAR, +}; pub use storage::*; pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 5f375e23c1c..46f36212cea 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -63,12 +63,6 @@ const CANONICAL_SKILL_DIR: &str = ".agents/skills/buzz-cli"; /// Nest directory name for production builds. const NEST_DIR_PROD: &str = ".buzz"; -/// Nest directory name for dev builds. Dev builds (those whose Tauri app-data -/// directory name starts with `"xyz.block.buzz.app.dev"`) use a separate nest -/// so that the DMG and dev-build instances don't clobber each other's -/// `.repos-dir` dotfile and `REPOS` symlink. -const NEST_DIR_DEV: &str = ".buzz-dev"; - /// Process-lifetime nest directory. Initialized once at startup via /// [`init_nest_dir`] before any call to [`nest_dir`]. /// @@ -88,8 +82,8 @@ static NEST_DIR: std::sync::OnceLock> = std::sync::OnceLock::new /// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`. /// Pass `false` for production (signed DMG) builds. pub fn init_nest_dir(is_dev: bool) { - let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD }; - let path = dirs::home_dir().map(|h| h.join(suffix)); + let suffix = crate::build_identity::nest_name(is_dev); + let path = dirs::home_dir().map(|h| h.join(suffix.as_ref())); // set() is a no-op when already initialized, which is correct: only the // first call (at boot, before any filesystem work) should win. let _ = NEST_DIR.set(path); @@ -315,12 +309,8 @@ fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { /// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a /// concurrent dev build each own a separate link and never clobber each other — /// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev). -pub fn cli_link_name(is_dev: bool) -> &'static str { - if is_dev { - "buzz-dev" - } else { - "buzz" - } +pub fn cli_link_name(is_dev: bool) -> String { + crate::build_identity::cli_name(is_dev) } /// Ensures `~/.local/bin/buzz` (prod) or `~/.local/bin/buzz-dev` (dev) is a diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index c6056d4b839..c712b2525d4 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -11,6 +11,7 @@ const TEST_RELAY: &str = "ws://example.com:3000"; fn make_persona(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: display_name.to_string(), avatar_url: None, @@ -37,6 +38,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), name: name.to_string(), persona_id: persona_id.map(|s| s.to_string()), diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 9aa1eeb0985..7d54c5a7b07 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -7,7 +7,7 @@ fn nest_dir_is_under_home() { // whether init_nest_dir was called before this test ran. let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir must end with .buzz or .buzz-dev, got {dir:?}" ); } @@ -23,7 +23,7 @@ fn init_nest_dir_prod_sets_buzz() { if let Some(d) = dir { let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir suffix must be .buzz or .buzz-dev, got {d:?}" ); } @@ -357,13 +357,19 @@ fn ensure_skill_symlinks_skip_dangling_symlink() { } #[test] -fn cli_link_name_prod_is_buzz() { - assert_eq!(cli_link_name(false), "buzz"); +fn cli_link_name_prod_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz".to_string()); + assert_eq!(cli_link_name(false), expected); } #[test] -fn cli_link_name_dev_is_buzz_dev() { - assert_eq!(cli_link_name(true), "buzz-dev"); +fn cli_link_name_dev_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz-dev".to_string()); + assert_eq!(cli_link_name(true), expected); } #[cfg(unix)] @@ -395,8 +401,8 @@ fn ensure_cli_symlink_creates_symlink_dev() { let local_bin = tmp.path().join("local_bin"); fs::create_dir_all(&local_bin).unwrap(); - // Dev link must be "buzz-dev", never "buzz". - assert_eq!(cli_link_name(true), "buzz-dev"); + // Dev and demo links must never overwrite production's "buzz". + assert_ne!(cli_link_name(true), "buzz"); let link = local_bin.join(cli_link_name(true)); std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index 27ee19eb67a..f0806c8bc04 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -64,6 +64,7 @@ mod tests { fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), name: "r".to_string(), persona_id: None, @@ -129,6 +130,7 @@ mod tests { ) -> crate::managed_agents::types::AgentDefinition { use crate::managed_agents::types::AgentDefinition; AgentDefinition { + description: None, id: id.to_string(), display_name: String::new(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 619122d9164..fa80b456a07 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -92,6 +92,14 @@ pub struct PersonaEventContent { pub respond_to_allowlist: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub parallelism: Option, + /// Optional short, PUBLIC description (max 280 chars). Appended after the + /// pre-existing fields so records without one serialize byte-identically + /// to the pre-description era — existing content bytes and event ids are + /// unchanged. EXCLUDED from [`persona_content_hash`]: description is + /// display metadata, not spawn-relevant config, so a description-only edit + /// must not badge linked instances as needing a restart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Derive the d-tag (persona slug) from a `AgentDefinition`. @@ -229,6 +237,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result String { use sha2::{Digest, Sha256}; - let json = serde_json::to_vec(content).unwrap_or_default(); + let hashed = PersonaEventContent { + description: None, + ..content.clone() + }; + let json = serde_json::to_vec(&hashed).unwrap_or_default(); let digest = Sha256::digest(&json); hex::encode(digest) } @@ -522,6 +540,7 @@ pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { respond_to: record.respond_to.clone(), respond_to_allowlist: record.respond_to_allowlist.clone(), parallelism: record.parallelism, + description: record.description.clone(), } } 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 ffbb575224d..9367ad463e2 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -5,6 +5,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// state right after creation, before any snapshot apply. pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".repeat(64), name: "agent".into(), persona_id: Some("test-persona".into()), @@ -144,6 +145,7 @@ fn preview_passes_through_unchanged_when_persona_missing() { pub(super) fn sample_persona() -> AgentDefinition { AgentDefinition { + description: None, id: "test-persona".to_string(), display_name: "Test Persona".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -319,6 +321,7 @@ fn content_matches_nip_ap_vector() { const VECTOR: &str = r#"{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}"#; let content = PersonaEventContent { + description: None, display_name: "Test Agent".to_string(), system_prompt: Some("You are a test assistant.".to_string()), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -372,6 +375,7 @@ fn content_matches_nip_ap_vector() { // signed content, so a second implementer following the spec computes // the same NIP-01 id. let record = AgentDefinition { + description: None, id: "test-agent".to_string(), display_name: "Test Agent".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -404,6 +408,7 @@ fn content_matches_nip_ap_vector() { #[test] fn round_trip_minimal_persona() { let record = AgentDefinition { + description: None, id: "minimal".to_string(), display_name: "Minimal".to_string(), avatar_url: None, @@ -502,6 +507,7 @@ fn behavioral_defaults_survive_record_round_trip() { #[test] fn quad_absent_definition_hash_stable_across_activation() { let record = AgentDefinition { + description: None, id: "quad-absent".to_string(), display_name: "Test".to_string(), avatar_url: None, @@ -547,6 +553,7 @@ fn quad_absent_definition_hash_stable_across_activation() { /// way `persona_from_event` maps fields, without needing a signed event. fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDefinition { AgentDefinition { + description: content.description, id: "staged".to_string(), display_name: content.display_name, avatar_url: content.avatar_url, @@ -574,6 +581,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef #[test] fn persona_content_hash_is_deterministic() { let content = PersonaEventContent { + description: None, display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), @@ -594,6 +602,7 @@ fn persona_content_hash_is_deterministic() { #[test] fn persona_content_hash_changes_on_edit() { let content1 = PersonaEventContent { + description: None, display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), @@ -613,6 +622,42 @@ fn persona_content_hash_changes_on_edit() { ); } +/// `description` is public display metadata, deliberately excluded from +/// `persona_content_hash`: two contents differing only in description must +/// hash identically, so a description-only edit never flips the +/// "restart required" drift badge on linked instances. +#[test] +fn description_change_does_not_change_content_hash() { + let without = PersonaEventContent { + description: None, + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: Some("Hello".to_string()), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + }; + let mut with = without.clone(); + with.description = Some("A friendly test agent.".to_string()); + assert_eq!( + persona_content_hash(&without), + persona_content_hash(&with), + "description must not participate in the content hash" + ); + + let mut edited = with.clone(); + edited.description = Some("A different description.".to_string()); + assert_eq!( + persona_content_hash(&with), + persona_content_hash(&edited), + "description-only edits must not change the content hash" + ); +} + // ── PersonaSnapshot.runtime ─────────────────────────────────────────────── /// (b) The snapshot carries the persona's runtime VERBATIM — including None, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 3c8a40231d4..094d0a1a478 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -124,6 +124,7 @@ fn built_in_persona_records(now: &str) -> Vec { id: persona.id.to_string(), display_name: persona.display_name.to_string(), avatar_url: persona.avatar_url.map(|s| s.to_string()), + description: None, system_prompt: persona.system_prompt.to_string(), runtime: persona.runtime.map(|s| s.to_string()), model: persona.model.map(|s| s.to_string()), diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 1fd8c3bccff..a52f6aa3b19 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -8,6 +8,7 @@ use crate::managed_agents::AgentDefinition; fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 909b97d652d..88cc7884c41 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -269,6 +269,20 @@ fn resolve_effective_agent_env_with_def( ); env.extend(user_env); + // Single harness-agnostic effort authority (PR #4625): resolve effective + // effort over the canonical column AND all env tiers, emit one destination + // key. Runs AFTER the layer stack so launch, remote deploy, and the restart + // snapshot agree — no double authority, no foreign key, no badge disagreement. + super::config_bridge::effort::apply_launch_effort( + &mut env, + record, + runtime, + personas, + &global.env_vars, + harness_def.as_deref(), + &baked_build_env(), + ); + // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. #[cfg(feature = "mesh-llm")] @@ -1049,6 +1063,8 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1241,6 +1257,8 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1473,9 +1491,9 @@ mod tests { "BUZZ_AGENT_MODEL".to_string(), "claude-opus-4-5".to_string(), ); - // Minimal record: only the fields resolve_effective_agent_env reads. let record = crate::managed_agents::types::ManagedAgentRecord { + description: None, pubkey: "test-pubkey".to_string(), name: "test-agent".to_string(), persona_id: None, @@ -1681,56 +1699,10 @@ mod tests { })); } - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } + // buzz-agent OpenRouter readiness tests live in a sibling file so this + // module stays under the desktop file-size ratchet. + #[path = "openrouter_tests.rs"] + mod openrouter_tests; } // Goose file-config-aware requirement tests live in a sibling file so this diff --git a/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs new file mode 100644 index 00000000000..73b3fcda4b8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs @@ -0,0 +1,57 @@ +//! buzz-agent OpenRouter readiness tests, split from `readiness.rs`'s `tests` +//! module so that file stays under the desktop file-size ratchet. +//! +//! Declared as a child of `mod tests` via `#[path]`, so `use super::*` resolves +//! against that module and reaches its `make_env`/`env_with` helpers. + +use super::*; + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4eb3..c01d29f3c2a 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -62,11 +62,17 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Desktop-owned pool lifetime policy: user env must not disable or reset // the idle worker-reclamation window while the desktop launcher sets it. "BUZZ_ACP_IDLE_POOL_SLEEP", + // Desktop experiment policy: the Settings toggle is the sole authority + // for whether channel threads receive independent ACP sessions. + "BUZZ_ACP_SESSION_POLICY", "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", + // Demo-build identity owns the child agent config root. A user override + // could silently reconnect a demo harness to production OAuth state. + "BUZZ_AGENT_CONFIG_DIR", // 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 diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index a225f492d33..5b79ccac27f 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,5 +1,6 @@ use super::{ - find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, + bestie_assignment::recover_pending_assignment_cleanup, find_managed_agent_mut, + kill_stale_tracked_processes, load_managed_agents, load_personas, managed_agents_base_dir, save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; @@ -114,6 +115,11 @@ pub async fn restore_managed_agents_on_launch( } let mut records = load_managed_agents(app)?; + recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; let mut runtimes = state .managed_agent_processes .lock() @@ -338,6 +344,7 @@ pub async fn restore_managed_agents_on_launch( &key.relay_url, true, owner_hex_ref, + None, ) }) { Ok(process) => { @@ -454,6 +461,10 @@ pub async fn restore_managed_agents_on_launch( pubkey: record.pubkey.clone(), agent_command: effective_command, persona_id: record.persona_id.clone(), + about: crate::managed_agents::record_effective_description( + record, + &reconcile_personas, + ), }, )) }) @@ -490,7 +501,7 @@ fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, workspace_relay: &str) { let state = app.state::(); if !state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(Ordering::Acquire) { return; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..b8d586b32af 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; -use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env}; +use super::agent_env::idle_pool_sleep_env; use crate::{ managed_agents::{ @@ -14,7 +14,7 @@ use crate::{ util::now_iso, }; -use super::claude_config::{apply_claude_model_env, apply_effort_env}; +use super::claude_config::apply_claude_model_env; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; @@ -23,10 +23,13 @@ pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondT mod metadata; pub(crate) use metadata::{ - apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars, - DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + apply_agent_display_env, apply_replay_floor_env, child_rust_log_filter, resolve_session_title, + runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, REPLAY_FLOOR_ENV_VAR, SESSION_TITLE_ENV_VAR, }; +mod setup_payload; +use setup_payload::apply_setup_payload_env; + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -109,7 +112,6 @@ pub(crate) fn workspace_pair_key( app: &AppHandle, record: &ManagedAgentRecord, ) -> Option { - use tauri::Manager; let state = app.state::(); resolve_workspace_pair_key( &record.pubkey, @@ -226,23 +228,14 @@ pub fn build_managed_agent_summary( } }; - // 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). - // - // 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 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. + // Restart badge: the running process stamped its effective spawn config; + // 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 processes have no stamp; other- + // community pairs are judged in their own community). Adapter drift + // (codex only) contributes a synthetic entry for out-of-band npm changes. + // Global config drives both snapshot and descriptor env layering; the + // caller loads it once so list callers pay one disk read per call. // The prospective side is computed only for a tracked pair: an unstamped // agent has nothing to compare against. @@ -254,6 +247,7 @@ pub fn build_managed_agent_summary( &key.relay_url, global_config, super::owner_only_access_build(), + super::acp_session_policy(app.state::().inner()), ); (runtime, current) }); @@ -397,18 +391,66 @@ pub(crate) fn configure_runtime_cli( } } +/// Proof token for the effort-application outer binding. `#[must_use]`; +/// makes `let effort = apply_effort_to_spawn_command(…)` a compile-time +/// requirement — deleting the binding is a compile error because +/// `spawn_with_effort_proof` consumes it by value. +/// +/// The private field prevents any crate-local code from constructing +/// `EffortApplied` directly (same shape as `RecordFieldsApplied(())`), so +/// the only way to obtain a token is to call `apply_effort_to_spawn_command`. +#[must_use] +pub(crate) struct EffortApplied(()); + +/// Apply effort env to an agent spawn command. Called by `spawn_agent_child` +/// (production) and `effort_cmd_tests` (test seam). Inner-seam: removing +/// `apply_spawn_effort_env` below turns the production-sequence tests RED. +/// Outer-seam: the returned token is consumed by `spawn_with_effort_proof`; +/// deleting this call leaves `effort` undefined at the spawn site. +pub(crate) fn apply_effort_to_spawn_command( + cmd: &mut std::process::Command, + record: &crate::managed_agents::types::ManagedAgentRecord, + runtime: Option<&crate::managed_agents::discovery::KnownAcpRuntime>, + personas: &[crate::managed_agents::types::AgentDefinition], + persona_id: Option<&str>, + global_env: &std::collections::BTreeMap, + baked_env: &std::collections::BTreeMap, +) -> EffortApplied { + super::config_bridge::effort::apply_spawn_effort_env( + cmd, record, runtime, personas, persona_id, global_env, baked_env, + ); + EffortApplied(()) +} + +/// Spawn the agent command, consuming the `EffortApplied` proof token. +/// Deleting `apply_effort_to_spawn_command` from `spawn_agent_child` leaves +/// `effort` undefined here — a compile error CI catches before any test runs. +pub(crate) fn spawn_with_effort_proof( + cmd: &mut std::process::Command, + _effort: EffortApplied, +) -> std::io::Result { + cmd.spawn() +} + /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. /// /// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy /// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`. +/// +/// `replay_floor_unix`: optional unix-seconds replay floor for the harness's +/// startup watermark (`BUZZ_ACP_REPLAY_FLOOR`). A publish-first mention send +/// publishes the triggering message before this spawn and passes its send +/// timestamp here so the harness's first REQ replays past that message no +/// matter how long the spawn takes. buzz-acp clamps stale floors to ~15 min. pub fn spawn_agent_child( app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, lazy: bool, owner_hex: Option<&str>, + replay_floor_unix: Option, ) -> Result { if let Some(error) = spawn_key_refusal(record) { return Err(error); @@ -501,7 +543,6 @@ pub fn spawn_agent_child( // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. let effective_relay_url = runtime_key.relay_url.clone(); - // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -534,6 +575,12 @@ pub fn spawn_agent_child( command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); + // Publish-first mention sends hand the harness the send timestamp as a + // startup replay floor. Strip any ambient value here — before the + // `descriptor.env` loop — so a floor from the parent environment can never + // leak into an unrelated spawn; the caller's floor is asserted AFTER that + // loop by `apply_replay_floor_env` so saved user env cannot shadow it. + command.env_remove(REPLAY_FLOOR_ENV_VAR); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -552,121 +599,9 @@ pub fn spawn_agent_child( } // ── Readiness check: set setup-payload if agent is not ready ───────────── - // - // Build the effective env the agent would have at start-time, run the - // readiness predicate, and if anything is missing, serialize the payload - // into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup - // and enters the minimal setup-listener mode instead of the agent pool. - // - // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env - // cannot set it, but we also explicitly remove it after writing user env - // to guard against the parent-process environment. We then set it only - // when desktop has computed NotReady — the desktop is the sole readiness - // source and buzz-acp only transports the payload. - // - // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: - // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } - // - // `spawned_setup_mode` is captured outside the block so it can be stamped - // on `ManagedAgentProcess` — used by `install_acp_runtime` to target only - // stuck agents for auto-restart. - let spawned_setup_mode; - { - use crate::managed_agents::readiness::EffectiveAgentEnv; - use crate::managed_agents::{agent_readiness, AgentReadiness, Requirement}; - - // Construct EffectiveAgentEnv from the descriptor computed above — no second - // resolver call; the descriptor's env is already the fully layered result. - let effective = EffectiveAgentEnv { - env: descriptor.env.clone(), - config_file_path: runtime_meta.and_then(|r| r.config_file_path), - effective_command: descriptor.command.clone(), - }; - // Compute the optional payload before touching the command. - let setup_payload_json = - if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { - let reqs: Vec = requirements - .into_iter() - .map(|r| match r { - Requirement::NormalizedField { field } => serde_json::json!({ - "surface": "normalized_field", - "field": field, - }), - Requirement::EnvKey { key } => serde_json::json!({ - "surface": "env_key", - "key": key, - }), - Requirement::CliLogin { - probe_args, - setup_copy, - availability, - } => serde_json::json!({ - "surface": "cli_login", - "probe_args": probe_args, - "setup_copy": setup_copy, - "availability": availability, - }), - Requirement::CliConfigInvalid { - probe_args, - setup_copy, - diagnostic, - } => serde_json::json!({ - "surface": "cli_config_invalid", - "probe_args": probe_args, - "setup_copy": setup_copy, - "diagnostic": diagnostic, - }), - Requirement::GitBash => serde_json::json!({ - "surface": "git_bash", - }), - Requirement::MissingBinary { command } => serde_json::json!({ - "surface": "missing_binary", - "command": command, - }), - }) - .collect(); - let payload = serde_json::json!({ - "agent_name": record.name, - "agent_pubkey": record.pubkey, - "requirements": reqs, - }); - match serde_json::to_string(&payload) { - Ok(json) => Some(json), - Err(e) => { - eprintln!( - "buzz-desktop: failed to serialize setup payload for {}: {e}", - record.name - ); - None - } - } - } else { - None - }; - - spawned_setup_mode = setup_payload_json.is_some(); - - // Strip the key from the process-spawned command on every path. - // Two independent guards protect the invariant: - // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so - // merged_user_env() can never write it via saved/persona env. - // 2. This env_remove() clears any ambient parent-process value - // inherited by std::process::Command before we conditionally - // set the desktop-computed trusted value below. - // Note: merged_user_env() is written further below in this function; - // ordering relative to that call is NOT what makes this safe — the - // reserved-key strip (guard 1) handles user env regardless of order. - command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); - - // Set the payload only when desktop computed NotReady. - if let Some(json) = setup_payload_json { - command.env("BUZZ_ACP_SETUP_PAYLOAD", json); - eprintln!( - "buzz-desktop: agent {} not ready — spawning in setup-listener mode", - record.name - ); - } - } + // `spawned_setup_mode` is stamped on `ManagedAgentProcess` below. + let spawned_setup_mode = + apply_setup_payload_env(&mut command, record, &descriptor, runtime_meta); // 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). @@ -742,7 +677,18 @@ pub fn spawn_agent_child( &mut command, resolve_session_title(record.display_name.as_deref(), &record.name), ); - build_buzz_agent_provider_defaults(&mut command); + // Strip all known effort keys and emit exactly one projected key. Command + // inherits the parent env — the returned EffortApplied token is consumed + // by spawn_with_effort_proof below; deleting this call is a compile error. + let effort = apply_effort_to_spawn_command( + &mut command, + record, + runtime_meta, + &personas, + record.persona_id.as_deref(), + &global.env_vars, + &super::agent_env::baked_build_env(), + ); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, @@ -809,14 +755,16 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } + // Resolve once and stamp the same value onto the snapshot below. + let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); - // B5: carry persisted effort; harness resolves thought_level configId at first session. - // Written AFTER descriptor.env so the canonical persisted value wins over any - // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern - // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is - // None there is no canonical value to assert, so env passthrough stands — user env - // legitimately seeds startup effort in that case. - apply_effort_env(&mut command, record.effort_level.as_deref()); + crate::build_identity::apply_demo_config_home(&mut command)?; + // Publish-first replay floor: written AFTER the `descriptor.env` loop, the + // same post-loop authority ordering the A1 model write uses. This send's + // floor is invocation state and must win over a saved + // BUZZ_ACP_REPLAY_FLOOR — the shadow `apply_replay_floor` strips from the + // provider payload's `launch.env` tier for the same reason. + apply_replay_floor_env(&mut command, replay_floor_unix); // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env @@ -847,10 +795,8 @@ 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. + // Stamp spawn config from values above, BEFORE spawning — a post-spawn + // re-resolve races config edits and would stamp the wrong values. let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs( super::spawn_snapshot::SpawnConfigInputs { record, @@ -861,11 +807,11 @@ pub fn spawn_agent_child( model: effective_model.as_deref(), provider: effective_provider.as_deref(), enforced_owner_only: super::owner_only_access_build(), + session_policy: acp_session_policy, }, ); - // Spawn the harness in its own process group so we can kill the entire - // tree (harness + MCP servers + agent subprocesses) on shutdown. + // Spawn in its own process group (Unix) or with CREATE_NO_WINDOW (Windows). #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -881,7 +827,7 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } - let child = command.spawn().map_err(|error| { + let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", resolved_acp_command.display(), @@ -889,14 +835,8 @@ pub fn spawn_agent_child( ) })?; - // 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). - // Non-codex runtimes get `None` — nothing changes for them. - // When the cache is cold (e.g. Doctor just installed and cleared the cache), - // `adapter_availability_cached()` returns `None`, so the stamp is `None` and - // the drift check is skipped until discovery warms the cache — preventing a - // false restart badge immediately after auto-restart. + // Codex: stamp adapter availability for the Phase-2 badge drift check. + // Cold cache returns `None` → drift check skipped until discovery warms it. let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") { super::adapter_availability_cached() } else { @@ -928,14 +868,6 @@ pub fn spawn_agent_child( }) } -fn child_rust_log_filter() -> String { - match std::env::var("RUST_LOG") { - Ok(existing) if existing.contains("buzz_acp") => existing, - Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), - _ => "buzz_acp=info".to_string(), - } -} - /// Spawn (or adopt) the runtime pair for `record` on the caller's bound /// workspace relay. `workspace_relay` can only be produced by /// `bind_expected_relay_scope`, so this spawn consumes — by construction — the @@ -948,6 +880,7 @@ pub fn start_managed_agent_process( runtimes: &mut HashMap, owner_hex: Option<&str>, workspace_relay: &crate::relay::ScopedWorkspaceRelay, + replay_floor_unix: Option, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; if let Some(runtime) = runtimes.get_mut(&key) { @@ -967,7 +900,14 @@ pub fn start_managed_agent_process( // Scalar PIDs are migration-only and never establish pair liveness. record.runtime_pid = None; - let mut process = spawn_agent_child(app, record, &key.relay_url, false, owner_hex)?; + let mut process = spawn_agent_child( + app, + record, + &key.relay_url, + false, + owner_hex, + replay_floor_unix, + )?; let now = now_iso(); let receipt = super::ManagedAgentRuntimeReceipt { key: key.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 5aef424ea61..769e20cedf6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -45,6 +45,40 @@ pub(crate) fn apply_agent_display_env(command: &mut std::process::Command, title } } +/// Env var carrying the startup replay floor to the harness. Shared with the +/// provider deploy path (`commands::agents::provider_deploy`) so the local +/// spawn and the remote `launch.policy_env` injection name the key from one +/// place. +pub(crate) const REPLAY_FLOOR_ENV_VAR: &str = "BUZZ_ACP_REPLAY_FLOOR"; + +/// Apply the publish-first replay floor: inject [`REPLAY_FLOOR_ENV_VAR`] from +/// `replay_floor_unix` (or leave the key untouched if `None`). +/// +/// Must be called **after** `descriptor.env` is written so this send's floor +/// wins over any user-supplied `BUZZ_ACP_REPLAY_FLOOR` entry — the same +/// authority ordering [`super::apply_effort_env`] asserts for effort, and the +/// same shadow strip `apply_replay_floor` performs on the provider payload's +/// `launch.env` tier. Without it a persona/global/agent env entry would +/// override the floor and the harness's startup watermark would be computed +/// from a stale (or `now`-clamped future) value, missing the mention that +/// triggered the spawn. +/// +/// When `replay_floor_unix` is `None` there is no floor to assert; the key is +/// left as `descriptor.env` wrote it, matching the provider path where a +/// user-supplied `launch.env` value passes through on a floorless deploy. The +/// caller strips the ambient parent-process value before the `descriptor.env` +/// loop, so `None` never inherits a floor from the environment Desktop itself +/// was launched with. +pub(crate) fn apply_replay_floor_env( + command: &mut std::process::Command, + replay_floor_unix: Option, +) { + if let Some(floor) = replay_floor_unix { + command.env(REPLAY_FLOOR_ENV_VAR, floor.to_string()); + } + // None: no floor to assert — leave whatever descriptor.env wrote intact. +} + /// 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 /// caller clears the env var rather than exporting an empty title. @@ -74,9 +108,91 @@ pub(crate) fn resolve_session_title(display_name: Option<&str>, name: &str) -> O .find(|value| !value.is_empty()) } +/// Build the `RUST_LOG` value forwarded to the agent child: keep an existing +/// filter that already mentions `buzz_acp`, append `buzz_acp=info` to any other +/// non-empty filter, and default to `buzz_acp=info` when unset. +pub(crate) fn child_rust_log_filter() -> String { + match std::env::var("RUST_LOG") { + Ok(existing) if existing.contains("buzz_acp") => existing, + Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), + _ => "buzz_acp=info".to_string(), + } +} + #[cfg(test)] mod tests { - use super::resolve_session_title; + use super::{apply_replay_floor_env, resolve_session_title, REPLAY_FLOOR_ENV_VAR}; + + fn replay_floor_of(cmd: &std::process::Command) -> Option { + cmd.get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new(REPLAY_FLOOR_ENV_VAR)) + .and_then(|(_, value)| value) + .map(|value| value.to_string_lossy().into_owned()) + } + + /// The publish-first floor must win over a persona/global/agent env entry + /// written by the `descriptor.env` loop. Before the post-loop application + /// the saved value shadowed the floor and the harness booted blind to the + /// mention that triggered the spawn. + #[test] + fn caller_replay_floor_wins_over_user_env_collision() { + let mut cmd = std::process::Command::new("true"); + // Simulate the descriptor.env loop writing a saved user value. + cmd.env(REPLAY_FLOOR_ENV_VAR, "1"); + + apply_replay_floor_env(&mut cmd, Some(1_756_600_000)); + + assert_eq!( + replay_floor_of(&cmd).as_deref(), + Some("1756600000"), + "this send's floor must win over the user-supplied value" + ); + } + + /// No caller floor: the user value passes through, matching the provider + /// payload path where a floorless deploy leaves `launch.env` untouched. + #[test] + fn user_replay_floor_env_survives_when_no_caller_floor() { + let mut cmd = std::process::Command::new("true"); + cmd.env(REPLAY_FLOOR_ENV_VAR, "1756600000"); + + apply_replay_floor_env(&mut cmd, None); + + assert_eq!( + replay_floor_of(&cmd).as_deref(), + Some("1756600000"), + "a user-supplied floor must survive when the caller supplies none" + ); + } + + /// The ambient strip the spawn does before the `descriptor.env` loop must + /// stay stripped when neither the caller nor user env supplies a floor. + #[test] + fn removed_replay_floor_stays_removed_without_caller_floor() { + let mut cmd = std::process::Command::new("true"); + // Simulate the spawn's pre-loop ambient strip with no user env entry. + cmd.env_remove(REPLAY_FLOOR_ENV_VAR); + + apply_replay_floor_env(&mut cmd, None); + + assert_eq!( + replay_floor_of(&cmd), + None, + "a floorless spawn must not inherit an ambient parent-process floor" + ); + } + + /// A caller floor re-asserts the key even after the pre-loop ambient strip + /// removed it — the common publish-first send with no saved user entry. + #[test] + fn caller_replay_floor_injected_after_ambient_strip() { + let mut cmd = std::process::Command::new("true"); + cmd.env_remove(REPLAY_FLOOR_ENV_VAR); + + apply_replay_floor_env(&mut cmd, Some(42)); + + assert_eq!(replay_floor_of(&cmd).as_deref(), Some("42")); + } #[test] fn resolve_session_title_prefers_display_name() { diff --git a/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs new file mode 100644 index 00000000000..6e3456c0795 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs @@ -0,0 +1,125 @@ +//! Setup-listener payload for a spawn whose agent is not ready to run. +//! +//! The desktop is the sole readiness source; buzz-acp only transports the +//! payload. Kept beside the spawn rather than inside it so the readiness → +//! JSON → env write path reads as one unit. + +use crate::managed_agents::readiness::{EffectiveAgentEnv, EffectiveHarnessDescriptor}; +use crate::managed_agents::{ + agent_readiness, AgentReadiness, KnownAcpRuntime, ManagedAgentRecord, Requirement, +}; + +/// Build the effective env the agent would have at start-time, run the +/// readiness predicate, and if anything is missing, serialize the payload into +/// `BUZZ_ACP_SETUP_PAYLOAD`. buzz-acp detects this env var on startup and +/// enters the minimal setup-listener mode instead of the agent pool. +/// +/// Returns whether the payload was set — stamped on `ManagedAgentProcess` and +/// used by `install_acp_runtime` to target only stuck agents for auto-restart. +/// +/// SECURITY: `BUZZ_ACP_SETUP_PAYLOAD` is in `RESERVED_ENV_KEYS` so user env +/// cannot set it, but we also explicitly remove it after writing user env to +/// guard against the parent-process environment. We then set it only when +/// desktop has computed `NotReady`. +/// +/// The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: +/// `{ "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] }` +pub(super) fn apply_setup_payload_env( + command: &mut std::process::Command, + record: &ManagedAgentRecord, + descriptor: &EffectiveHarnessDescriptor, + runtime_meta: Option<&'static KnownAcpRuntime>, +) -> bool { + // Construct EffectiveAgentEnv from the descriptor the caller resolved — no + // second resolver call; the descriptor's env is already the fully layered + // result. + let effective = EffectiveAgentEnv { + env: descriptor.env.clone(), + config_file_path: runtime_meta.and_then(|r| r.config_file_path), + effective_command: descriptor.command.clone(), + }; + // Compute the optional payload before touching the command. + let setup_payload_json = + if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { + let reqs: Vec = requirements + .into_iter() + .map(|r| match r { + Requirement::NormalizedField { field } => serde_json::json!({ + "surface": "normalized_field", + "field": field, + }), + Requirement::EnvKey { key } => serde_json::json!({ + "surface": "env_key", + "key": key, + }), + Requirement::CliLogin { + probe_args, + setup_copy, + availability, + } => serde_json::json!({ + "surface": "cli_login", + "probe_args": probe_args, + "setup_copy": setup_copy, + "availability": availability, + }), + Requirement::CliConfigInvalid { + probe_args, + setup_copy, + diagnostic, + } => serde_json::json!({ + "surface": "cli_config_invalid", + "probe_args": probe_args, + "setup_copy": setup_copy, + "diagnostic": diagnostic, + }), + Requirement::GitBash => serde_json::json!({ + "surface": "git_bash", + }), + Requirement::MissingBinary { command } => serde_json::json!({ + "surface": "missing_binary", + "command": command, + }), + }) + .collect(); + let payload = serde_json::json!({ + "agent_name": record.name, + "agent_pubkey": record.pubkey, + "requirements": reqs, + }); + match serde_json::to_string(&payload) { + Ok(json) => Some(json), + Err(e) => { + eprintln!( + "buzz-desktop: failed to serialize setup payload for {}: {e}", + record.name + ); + None + } + } + } else { + None + }; + + // Strip the key from the process-spawned command on every path. + // Two independent guards protect the invariant: + // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so + // merged_user_env() can never write it via saved/persona env. + // 2. This env_remove() clears any ambient parent-process value + // inherited by std::process::Command before we conditionally + // set the desktop-computed trusted value below. + // Note: merged_user_env() is written later in the caller; ordering + // relative to that call is NOT what makes this safe — the reserved-key + // strip (guard 1) handles user env regardless of order. + command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); + + // Set the payload only when desktop computed NotReady. + let Some(json) = setup_payload_json else { + return false; + }; + command.env("BUZZ_ACP_SETUP_PAYLOAD", json); + eprintln!( + "buzz-desktop: agent {} not ready — spawning in setup-listener mode", + record.name + ); + true +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index ec78cc14efa..05e11fc4cdf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -36,6 +36,7 @@ pub(super) fn fixture( auth_tag: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".into(), name: "n".into(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 24fad1461c5..57521c04fff 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -265,7 +265,6 @@ fn build_env_rejects_empty_allowlist_in_allowlist_mode() { } // ── persona fixture helpers ───────────────────────────────────────── - fn persona_with_provider( id: &str, prompt: &str, @@ -273,6 +272,7 @@ fn persona_with_provider( provider: Option<&str>, ) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { + description: None, id: id.to_string(), display_name: id.to_string(), avatar_url: None, @@ -1209,12 +1209,11 @@ fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord { fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRuntime { use std::process::{Command, Stdio}; - // Spawn a real child so ManagedAgentProcess's Child field is satisfied. - // `true` exits immediately with 0 — just a handle we need for type purposes. - // Absolute `/usr/bin/true` on unix (present on both macOS and Linux): - // parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a - // bare `true` lookup during that window fails with NotFound (observed - // flake). Windows keeps the PATH lookup — no test there swaps PATH. + // Spawn a real child so ManagedAgentProcess's Child field is satisfied; + // `true` exits immediately with 0. Absolute `/usr/bin/true` on unix (both + // macOS and Linux): parallel tests holding `lock_path_mutex` swap PATH to a + // tempdir, and a bare `true` lookup during that window fails NotFound + // (observed flake). Windows keeps the PATH lookup — no test there swaps it. #[cfg(unix)] let program = "/usr/bin/true"; #[cfg(windows)] @@ -1235,6 +1234,7 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun "wss://relay.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ), setup_mode: false, adapter_availability: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 135224d01db..ba0f91c9f7a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -288,7 +288,8 @@ fn start_pair( .lock() .ok() .map(|keys| keys.public_key().to_hex()); - let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref())?; + let mut process = + spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { key: key.clone(), diff --git a/desktop/src-tauri/src/managed_agents/session_policy.rs b/desktop/src-tauri/src/managed_agents/session_policy.rs new file mode 100644 index 00000000000..eb723908cab --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/session_policy.rs @@ -0,0 +1,140 @@ +use std::{ + collections::BTreeMap, + sync::atomic::{AtomicBool, Ordering}, +}; + +use tauri::{AppHandle, Manager}; + +use crate::app_state::AppState; + +pub(crate) const ACP_SESSION_POLICY_ENV_VAR: &str = "BUZZ_ACP_SESSION_POLICY"; + +/// Desktop experiment state that influences managed-agent lifecycle behavior. +pub struct ManagedAgentExperimentState { + pub(crate) profile_reconcile_enabled: AtomicBool, + pub(crate) thread_scoped_acp_sessions_enabled: AtomicBool, +} + +impl Default for ManagedAgentExperimentState { + fn default() -> Self { + Self { + profile_reconcile_enabled: AtomicBool::new(true), + thread_scoped_acp_sessions_enabled: AtomicBool::new(false), + } + } +} + +impl AppState { + pub(crate) fn managed_agent_profile_reconcile_enabled(&self) -> &AtomicBool { + &self.managed_agent_experiments.profile_reconcile_enabled + } + + pub(crate) fn thread_scoped_acp_sessions_enabled(&self) -> &AtomicBool { + &self + .managed_agent_experiments + .thread_scoped_acp_sessions_enabled + } +} + +/// Desktop-owned ACP session policy applied to every managed-agent launch. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AcpSessionPolicy { + Channel, + Thread, +} + +impl AcpSessionPolicy { + pub(crate) fn from_thread_scoped_enabled(enabled: bool) -> Self { + if enabled { + Self::Thread + } else { + Self::Channel + } + } + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Channel => "channel", + Self::Thread => "thread", + } + } +} + +/// Resolve the persisted experiment state at the shared launch boundary. +pub(crate) fn acp_session_policy(state: &AppState) -> AcpSessionPolicy { + AcpSessionPolicy::from_thread_scoped_enabled( + state + .thread_scoped_acp_sessions_enabled() + .load(Ordering::Acquire), + ) +} + +pub(crate) fn apply_acp_session_policy_env( + command: &mut std::process::Command, + policy: AcpSessionPolicy, +) { + command.env(ACP_SESSION_POLICY_ENV_VAR, policy.as_str()); +} + +/// Resolve the effective policy, apply it to `command`, and return it so the +/// caller can stamp the same value onto the spawn snapshot (env and badge can +/// never disagree about what the child launched with). +pub(crate) fn apply_app_acp_session_policy_env( + app: &AppHandle, + command: &mut std::process::Command, +) -> AcpSessionPolicy { + let policy = acp_session_policy(app.state::().inner()); + apply_acp_session_policy_env(command, policy); + policy +} + +pub(crate) fn insert_acp_session_policy_env( + policy_env: &mut BTreeMap, + policy: AcpSessionPolicy, +) { + policy_env.insert( + ACP_SESSION_POLICY_ENV_VAR.to_string(), + policy.as_str().to_string(), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn command_policy(command: &std::process::Command) -> Option<&str> { + command + .get_envs() + .find(|(key, _)| *key == ACP_SESSION_POLICY_ENV_VAR) + .and_then(|(_, value)| value) + .and_then(std::ffi::OsStr::to_str) + } + + #[test] + fn absent_or_disabled_experiment_selects_channel_policy() { + assert_eq!( + AcpSessionPolicy::from_thread_scoped_enabled(false), + AcpSessionPolicy::Channel + ); + assert_eq!(AcpSessionPolicy::Channel.as_str(), "channel"); + } + + #[test] + fn enabled_experiment_selects_thread_policy() { + assert_eq!( + AcpSessionPolicy::from_thread_scoped_enabled(true), + AcpSessionPolicy::Thread + ); + assert_eq!(AcpSessionPolicy::Thread.as_str(), "thread"); + } + + #[test] + fn local_launch_env_receives_the_selected_policy() { + let mut command = std::process::Command::new("true"); + command.env(ACP_SESSION_POLICY_ENV_VAR, "ambient"); + + apply_acp_session_policy_env(&mut command, AcpSessionPolicy::Thread); + + assert_eq!(command_policy(&command), Some("thread")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 8a6f68a693d..810ad439f29 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -31,14 +31,13 @@ use std::collections::BTreeMap; use serde::Serialize; use super::{ - claude_config::EFFORT_LEVEL_ENV_VAR, 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, + AcpSessionPolicy, GlobalAgentConfig, }; pub(crate) mod diff; @@ -76,6 +75,12 @@ pub(crate) struct SpawnConfigInputs<'a> { /// Compile-time distribution capability projected at this runtime boundary. /// The stored record remains portable; only effective spawned access is stamped. pub enforced_owner_only: bool, + /// The effective ACP session policy (`channel`/`thread`) the launch applies. + /// Resolved from the desktop experiment toggle at the shared launch + /// boundary; captured here so flipping the experiment while an agent runs + /// drives the existing restart-required path (the harness only reads + /// `BUZZ_ACP_SESSION_POLICY` at launch). + pub session_policy: AcpSessionPolicy, } /// The effective spawn configuration of one managed-agent process. @@ -128,30 +133,49 @@ pub(crate) struct SpawnConfigSnapshot { pub max_turn_duration_seconds: Option, pub parallelism: u32, /// The startup effort the harness will actually apply, resolved by - /// [`effective_effort`]: the persisted canonical `record.effort_level` when - /// present, else the user-seeded `BUZZ_ACP_EFFORT_LEVEL` from the layered - /// env. This is the *sole* representation of effort in the snapshot — the - /// key is stripped from `env` (see `from_inputs`) so an authority handoff - /// that leaves the effective value unchanged (canonical `low` replacing a - /// user env `low`, or the reverse) produces no spurious drift entry, and an - /// env-only edit still surfaces as exactly one `effort_level` entry. + /// [`effective_effort`]: the single effort key the harness-agnostic + /// projection left in `descriptor.env` under the runtime's destination key. + /// This is the *sole* representation of the effective effort in the + /// snapshot: the projection's destination key is stripped from `env` (see + /// `from_inputs`) so an authority handoff that leaves the effective value + /// unchanged produces no spurious drift entry, and an effort edit the + /// projection consumed surfaces as exactly one `effort_level` entry. For an + /// unknown/custom runtime the projection consumes nothing beyond the + /// sentinel, so any other effort-looking key the child receives stays in + /// `env` as ordinary state and diffs normally. pub effort_level: Option, + /// The effective ACP session policy this launch applies (`channel` or + /// `thread`). The harness reads `BUZZ_ACP_SESSION_POLICY` only at launch, so + /// capturing the resolved policy here lets a toggle flip while an agent runs + /// raise the restart-required badge instead of silently leaving the running + /// process on the old policy. Written directly on the spawn `Command` (not + /// via layered env), so it must be captured explicitly rather than read back + /// out of `env`. + pub session_policy: String, } -/// The startup effort a spawn would actually apply, mirroring `apply_effort_env` -/// exactly: the persisted canonical `record.effort_level` wins, and only when it -/// is absent does a user-supplied `BUZZ_ACP_EFFORT_LEVEL` from the layered env -/// seed startup effort. This is the resolver input for the snapshot's single -/// `effort_level` representation; the same precedence runs at spawn time in -/// `runtime.rs`, so badge and process can never disagree. -pub(crate) fn effective_effort( - record: &ManagedAgentRecord, - descriptor_env: &BTreeMap, -) -> Option { - record - .effort_level - .clone() - .or_else(|| descriptor_env.get(EFFORT_LEVEL_ENV_VAR).cloned()) +/// The startup effort a spawn actually applied, read from the single effort key +/// the harness-agnostic projection left in `descriptor.env`. +/// +/// The projection (`config_bridge::effort`) ran inside the descriptor resolver, +/// resolving the effective value over the canonical column and every env tier, +/// then reducing the env to exactly one effort key under the runtime's +/// destination key (`effort_dest_key`). Reading that key here means the badge +/// compares precisely what launched — no separate precedence to drift from the +/// spawn path, and an invalid canonical that fell through to an inherited tier +/// is reflected as the inherited value, not the raw column. +pub(crate) fn effective_effort(descriptor: &EffectiveHarnessDescriptor) -> Option { + let runtime = known_acp_runtime(&descriptor.command); + let dest_key = super::config_bridge::effort::effort_dest_key(runtime); + // Read case-insensitively (exact-first) so a mixed-case sentinel a custom + // runtime passed through (the projection uses an EMPTY suppress set, so a + // user-set `buzz_acp_effort_level` survives into `descriptor.env` and the + // child reads it as `BUZZ_ACP_EFFORT_LEVEL` on Windows) is captured here. + // The read must match the snapshot strip, which is also case-insensitive: + // if the read were exact-case it would miss the mixed-case sentinel, the + // strip would still remove it, and the value would land in neither + // `snapshot.env` nor `effort_level` — producing no restart diff on an edit. + super::config_bridge::effort::get_ci(&descriptor.env, dest_key).cloned() } impl SpawnConfigSnapshot { @@ -166,6 +190,7 @@ impl SpawnConfigSnapshot { model, provider, enforced_owner_only, + session_policy, } = inputs; let (respond_to, respond_to_allowlist) = super::projected_access_with_policy(record, enforced_owner_only); @@ -178,14 +203,27 @@ impl SpawnConfigSnapshot { .unwrap_or("") .to_string(), // Effort has ONE representation in the snapshot: `effort_level` - // below, always holding `effective_effort`. Stripping the env key - // here means a canonical/user-env authority handoff at the same - // value is a no-op (no phantom `env.BUZZ_ACP_EFFORT_LEVEL` add or - // remove) and an env-only effort edit surfaces as exactly one - // `effort_level` entry rather than a duplicate under `env.`. + // below, always holding the projected effective value. The keys + // stripped here mirror EXACTLY what the launch projection suppressed + // for this runtime (`snapshot_suppress_keys`): a known runtime swept + // every effort key to its single destination key, so the full set is + // stripped (a no-op beyond that dest key); an unknown/custom runtime + // used an empty suppress set (external-review-#2 pass-through), so + // only the ACP-startup sentinel is stripped and every other + // effort-looking key the child actually receives (e.g. a hand-rolled + // `GOOSE_THINKING_EFFORT`) stays as ordinary env — an edit to it must + // diff the snapshot and fire the restart badge. Stripping is + // ASCII-case-insensitive to match the projection's `apply`. env: { let mut env = descriptor.env.clone(); - env.remove(EFFORT_LEVEL_ENV_VAR); + let suppress = super::config_bridge::effort::snapshot_suppress_keys( + known_acp_runtime(&descriptor.command), + ); + env.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); env }, relay_url: relay_url.to_string(), @@ -215,10 +253,11 @@ impl SpawnConfigSnapshot { // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), // Sole effort representation — see the field doc and the `env` - // strip above. Resolver reads the record's canonical value and the - // raw descriptor env (before the strip), so a user-seeded env value - // is preserved as the effective effort when no canonical is set. - effort_level: effective_effort(record, &descriptor.env), + // strip above. Reads the single projected effort key the descriptor + // resolver left in `descriptor.env`, so the badge compares exactly + // what launched regardless of which tier supplied the value. + effort_level: effective_effort(descriptor), + session_policy: session_policy.as_str().to_string(), } } @@ -259,6 +298,7 @@ pub(crate) fn prospective_spawn_config_snapshot( workspace_relay: &str, global: &GlobalAgentConfig, enforced_owner_only: bool, + session_policy: AcpSessionPolicy, ) -> SpawnConfigSnapshot { // Prospective re-snapshot: apply the same `apply_persona_snapshot` the // start/restore paths run right before spawning, so this describes what a @@ -309,6 +349,7 @@ pub(crate) fn prospective_spawn_config_snapshot( model: model.as_deref(), provider: provider.as_deref(), enforced_owner_only, + session_policy, }) } 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 index 79bf4f77e79..43ce7718595 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -29,6 +29,7 @@ fn base() -> SpawnConfigSnapshot { max_turn_duration_seconds: Some(7200), parallelism: 1, effort_level: Some("high".into()), + session_policy: "channel".into(), } } @@ -72,6 +73,7 @@ fn mutations() -> Vec { }), ("parallelism", |s| s.parallelism = 8), ("effort_level", |s| s.effort_level = None), + ("session_policy", |s| s.session_policy = "thread".into()), ] } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index bcd93da851e..388256e01c6 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -20,6 +20,7 @@ fn snapshot_with_policy( workspace_relay, global, enforced_owner_only, + AcpSessionPolicy::Channel, ) .canonical() } @@ -42,6 +43,7 @@ fn snap(record: &ManagedAgentRecord) -> serde_json::Value { fn record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".repeat(64), name: "agent".into(), persona_id: None, @@ -103,6 +105,7 @@ fn record() -> ManagedAgentRecord { fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.into(), display_name: id.into(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index dd708b6e59e..b5ee8d45224 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -1,5 +1,5 @@ //! B5 effort lifecycle tests split out of `spawn_snapshot/tests.rs` to hold -//! that file under the 1000-line file-size ratchet. +//! that file under the 1500-line file-size ratchet. //! //! Included as `mod ext` inside `tests.rs`, so `use super::*` gives access to //! its `record`, `snap`, and `record_with_env_effort` helpers. @@ -27,86 +27,112 @@ fn effort_set_then_cleared_round_trips_to_no_effort_projection() { } #[test] -fn shadowed_user_env_effort_edit_under_canonical_is_empty_diff() { - // Canonical `high` shadows the user env seed. Editing that seed low→medium - // changes nothing effective (canonical wins and the env key is stripped), - // so the projections are identical and no badge lights. - let mut low_env = record_with_env_effort("low"); - low_env.effort_level = Some("high".into()); - let mut medium_env = record_with_env_effort("medium"); - medium_env.effort_level = Some("high".into()); +fn canonical_edit_under_record_native_env_is_empty_diff() { + // For Goose, the record-native env key `GOOSE_THINKING_EFFORT` outranks the + // canonical column (CLEAR authority order). With a record-native `low` + // present, editing the shadowed canonical high→medium changes nothing + // effective, so the projections are identical and no badge lights. + let mut high_col = record_with_env_effort("low"); + high_col.effort_level = Some("high".into()); + let mut medium_col = record_with_env_effort("low"); + medium_col.effort_level = Some("medium".into()); assert_eq!( - snap(&low_env), - snap(&medium_env), - "editing a canonical-shadowed user env must not badge" + snap(&high_col), + snap(&medium_col), + "editing a record-native-env-shadowed canonical must not badge" ); } #[test] -fn clearing_canonical_reveals_env_fallback_and_creates_a_diff() { - // Canonical `high` over a user env seed `low`: clearing the canonical drops - // the effective effort to the env fallback `low`, a real change that badges. - let mut canonical = record_with_env_effort("low"); - canonical.effort_level = Some("high".into()); - let env_only = record_with_env_effort("low"); +fn clearing_record_native_env_reveals_canonical_and_creates_a_diff() { + // Record-native env `low` shadows canonical `high`: removing the record env + // key drops resolution to the canonical `high`, a real change that badges. + let mut env_over_canonical = record_with_env_effort("low"); + env_over_canonical.effort_level = Some("high".into()); + let mut canonical_only = goose_record(); + canonical_only.effort_level = Some("high".into()); assert_ne!( - snap(&canonical), - snap(&env_only), - "clearing canonical must reveal the env fallback and badge" + snap(&env_over_canonical), + snap(&canonical_only), + "removing the record-native env must reveal the canonical and badge" ); } -// ── B5 effort: single canonical representation ─────────────────────────── +// ── Effort: single canonical representation ────────────────────────────── // // `effective_effort` and the snapshot's `effort_level` field are the sole -// carrier of startup effort. `BUZZ_ACP_EFFORT_LEVEL` is stripped from the -// snapshot `env` so an authority handoff at an unchanged effective value -// (canonical replacing a user-env seed, or the reverse) raises no spurious -// restart badge, while a genuine effort change surfaces exactly once. +// carrier of startup effort. Every effort key is stripped from the snapshot +// `env` so an authority handoff at an unchanged effective value raises no +// spurious restart badge, while a genuine effort change surfaces exactly once. -/// Look up the `env.BUZZ_ACP_EFFORT_LEVEL` leaf of a canonical snapshot, if any. +/// Look up the `env.GOOSE_THINKING_EFFORT` leaf of a canonical snapshot, if any +/// (the record()'s runtime is Goose, so this is its destination key). fn effort_env_leaf(canonical: &serde_json::Value) -> Option<&serde_json::Value> { canonical .get("env") - .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")) + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) } -/// A record whose user env seeds `BUZZ_ACP_EFFORT_LEVEL` (the pre-canonical -/// authority: no persisted `effort_level`, effort comes from user env_vars). +/// A Goose record whose record-native env seeds `GOOSE_THINKING_EFFORT` (the +/// top authority tier for Goose: effort comes from user env_vars, no column). +/// Pins `runtime = "goose"` so the effective command resolves to Goose and +/// `GOOSE_THINKING_EFFORT` is the record-*native* key — without it the record +/// falls back to the default `buzz-agent` runtime, for which that key is a +/// foreign env alias the projection suppresses rather than an authority tier. fn record_with_env_effort(value: &str) -> ManagedAgentRecord { let mut rec = record(); + rec.runtime = Some("goose".into()); rec.env_vars - .insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into()); + .insert("GOOSE_THINKING_EFFORT".into(), value.into()); rec } -#[test] -fn effective_effort_prefers_persisted_canonical_over_user_env() { - // Canonical wins, mirroring spawn's `apply_effort_env` (written after the - // user env layer). The env value is ignored when a canonical is present. +/// A Goose record with no effort env: the canonical column is the authority. +fn goose_record() -> ManagedAgentRecord { let mut rec = record(); - rec.effort_level = Some("high".into()); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("high")); + rec.runtime = Some("goose".into()); + rec } #[test] -fn effective_effort_falls_back_to_user_env_when_no_canonical() { - // No persisted canonical → the user-seeded env value is the effective - // startup effort, exactly what a spawn would leave in place. - let rec = record(); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("low")); +fn effective_effort_reads_the_projected_key_for_the_runtime() { + // The projection reduced the descriptor env to one effort key under the + // runtime's destination key. `effective_effort` reads exactly that key. + // A Goose descriptor carries `GOOSE_THINKING_EFFORT`. + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([("GOOSE_THINKING_EFFORT".to_string(), "high".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("high")); } #[test] -fn effective_effort_is_none_without_canonical_or_env() { - assert_eq!(effective_effort(&record(), &BTreeMap::new()), None); +fn effective_effort_reads_acp_sentinel_for_keyless_runtime() { + // Claude/Codex/keyless-ACP descriptors carry the effective value under the + // ACP-startup sentinel, which is the destination key for a runtime with no + // native thinking-effort env var (here: the claude adapter command). + let descriptor = EffectiveHarnessDescriptor { + command: "claude-code-acp".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("low")); +} + +#[test] +fn effective_effort_is_none_without_a_projected_key() { + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + assert_eq!(effective_effort(&descriptor), None); } #[test] fn snapshot_carries_effort_in_field_not_env() { - // Always-canonicalize: a user-seeded effort reaches the snapshot ONLY as + // Always-canonicalize: a record-native effort reaches the snapshot ONLY as // the `effort_level` field; the raw env key is stripped so effort has one // representation, never two. let canonical = snap(&record_with_env_effort("low")); @@ -118,50 +144,63 @@ fn snapshot_carries_effort_in_field_not_env() { assert_eq!( effort_env_leaf(&canonical), None, - "BUZZ_ACP_EFFORT_LEVEL must be stripped from the snapshot env" + "GOOSE_THINKING_EFFORT must be stripped from the snapshot env" ); } #[test] -fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { - // User env `low` (no canonical) → persisted canonical `low` while the env - // seed remains: the effective effort is `low` either way, so a restart - // would change nothing. Old raw-env snapshots would have shown drift; the - // single canonical representation makes the projections identical. - let env_authority = record_with_env_effort("low"); - let mut canonical_authority = record_with_env_effort("low"); - canonical_authority.effort_level = Some("low".into()); +fn foreign_transport_sentinel_is_suppressed_for_goose() { + // A user-seeded `BUZZ_ACP_EFFORT_LEVEL` is a foreign transport key for a + // Goose descriptor: never an authority tier, and stripped from the snapshot + // env by the suppress set. Editing it low→medium changes nothing. + let mut low = record(); + low.env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let mut medium = record(); + medium + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "medium".into()); assert_eq!( - snap(&env_authority), - snap(&canonical_authority), - "an authority handoff at the same effort value must not badge" + snap(&low), + snap(&medium), + "a foreign transport effort key must be suppressed for Goose and never badge" + ); + let canonical = snap(&low); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")), + None, + "the foreign sentinel must be stripped from the snapshot env" ); } #[test] -fn equal_value_effort_authority_handoff_canonical_to_env_is_no_op() { - // The reverse direction: canonical `low` (env seed present) → env `low` - // only (canonical cleared). Effective effort stays `low`; no badge. +fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { + // Record-native env `low` (no column) → canonical column `low` while the + // record env remains: the effective effort is `low` either way (env wins, + // but the value is identical), so a restart would change nothing. + let env_authority = record_with_env_effort("low"); let mut canonical_authority = record_with_env_effort("low"); canonical_authority.effort_level = Some("low".into()); - let env_authority = record_with_env_effort("low"); assert_eq!( - snap(&canonical_authority), snap(&env_authority), - "clearing the canonical while the env seed holds the same value must not badge" + snap(&canonical_authority), + "an authority handoff at the same effort value must not badge" ); } #[test] fn env_only_effort_edit_changes_effort_level_not_env() { - // An env-only effort edit (no canonical) moves the single `effort_level` - // representation and never reintroduces an `env.BUZZ_ACP_EFFORT_LEVEL` - // leaf, so the diff names `effort_level` once rather than duplicating it. + // A record-native env effort edit (no column) moves the single + // `effort_level` representation and never reintroduces a + // `env.GOOSE_THINKING_EFFORT` leaf, so the diff names `effort_level` once + // rather than duplicating it. let low = snap(&record_with_env_effort("low")); let high = snap(&record_with_env_effort("high")); assert_ne!( low, high, - "an env-only effort edit must change the snapshot" + "a record-native effort edit must change the snapshot" ); assert_eq!( low.get("effort_level").and_then(|v| v.as_str()), @@ -187,3 +226,234 @@ fn canonical_effort_edit_changes_snapshot() { "a canonical effort edit must trip the restart badge" ); } + +/// A custom-command record whose runtime matches no known ACP runtime, so the +/// launch projection suppresses ONLY its own ACP sentinel (external-review-#2 +/// pass-through, r5): every foreign effort key survives untouched and the child +/// receives its raw effort env. +fn custom_command_record() -> ManagedAgentRecord { + let mut rec = record(); + rec.agent_command_override = Some("/opt/custom/my-agent".into()); + rec +} + +#[test] +fn custom_runtime_effort_env_stays_in_snapshot_and_diffs() { + // Regression (external review, Carl): for an unknown/custom runtime the + // launch projection strips only its own ACP sentinel, so the child receives + // the raw `GOOSE_THINKING_EFFORT` from the wrapper's env. The snapshot must + // retain that key as ordinary env — the projection consumed nothing into + // `effort_level` (its dest key, the ACP sentinel, is absent) — so an edit to + // it diffs the snapshot and fires the restart badge. The prior full strip + // erased the key from both places, producing NO restart diff on an effort + // edit and leaving the running agent on stale effort. + let mut high = custom_command_record(); + high.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) + .and_then(|v| v.as_str()), + Some("high"), + "a custom runtime's effort env must remain in the snapshot as ordinary env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + None, + "the custom sentinel dest key is absent, so effort_level captures nothing" + ); + + let mut low = custom_command_record(); + low.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a custom runtime's effort env must trip the restart badge" + ); +} + +#[test] +fn known_runtime_still_strips_native_effort_env_from_snapshot() { + // The counter-case pinning the scoping: for a KNOWN runtime the full sweep + // still applies, so `GOOSE_THINKING_EFFORT` reaches the snapshot only as the + // single `effort_level` field — never as a phantom `env` entry alongside it. + let canonical = snap(&record_with_env_effort("high")); + assert_eq!( + effort_env_leaf(&canonical), + None, + "a known runtime must still strip its native effort key from the snapshot env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the known runtime's effort must land solely in the effort_level field" + ); +} + +#[test] +fn custom_runtime_mixed_case_sentinel_is_captured_not_lost() { + // Regression (external review, Carl, P2): for an unknown/custom runtime a + // user-set mixed-case `buzz_acp_effort_level` (no column) must not vanish. + // The launch projection now reconciles it — stripping the mixed-case + // spelling and re-emitting the pass-through value under the canonical + // `BUZZ_ACP_EFFORT_LEVEL` (see `effort_tests:: + // unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column`) + // — so `descriptor.env` carries exactly one canonical sentinel. The snapshot + // captures it into `effort_level` and strips it from `env`. Before the r4/r5 + // fixes the mixed-case key survived while the exact-case read missed it, so + // the value vanished from BOTH fields and an edit produced no restart diff. + let mut high = custom_command_record(); + high.env_vars + .insert("buzz_acp_effort_level".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "a mixed-case pass-through sentinel must be captured into effort_level" + ); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("buzz_acp_effort_level")), + None, + "the sentinel is the projection's dest key and is stripped from env once represented" + ); + + // The mutation pin: editing the mixed-case sentinel must trip the badge. + // Reverting the fix (exact-case read + case-insensitive strip, or an empty + // unknown-runtime suppress set) makes both snapshots carry + // `effort_level = null` with the key stripped, so they compare equal and + // this assertion fails. + let mut low = custom_command_record(); + low.env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a mixed-case custom-runtime sentinel must trip the restart badge" + ); +} + +#[test] +fn custom_runtime_canonical_column_wins_over_mixed_case_sentinel() { + // The with-canonical-column collision case Carl asked for, verified at the + // SNAPSHOT here and — decisively — at the projection/descriptor seam in + // `effort_tests::unknown_runtime_column_wins_over_mixed_case_sentinel`. The + // projection strips every case variant of the sentinel before emitting the + // column value, so `descriptor.env` carries exactly `BUZZ_ACP_EFFORT_LEVEL= + // ` and the child receives the column value on every platform (no + // lowercase variant survives for Windows to case-fold over the canonical + // key). This snapshot therefore reads the same truth the child gets: the + // column wins `effort_level` and both case variants are absent from `env`. + let mut high_col = custom_command_record(); + high_col.effort_level = Some("high".into()); + high_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + let canonical = snap(&high_col); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the canonical column wins effort_level over the pass-through sentinel" + ); + let env = canonical.get("env").expect("snapshot has an env object"); + assert_eq!( + env.get("BUZZ_ACP_EFFORT_LEVEL"), + None, + "the projection-emitted canonical sentinel is stripped from env" + ); + assert_eq!( + env.get("buzz_acp_effort_level"), + None, + "the user's mixed-case sentinel duplicate is stripped case-insensitively" + ); + + // Editing the authority (the column) still trips the badge. + let mut low_col = custom_command_record(); + low_col.effort_level = Some("low".into()); + low_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low_col), + canonical, + "editing the canonical column must trip the restart badge" + ); +} + +use crate::managed_agents::spawn_snapshot::{ + eligible_restart_diff, prospective_spawn_config_snapshot, RestartDiffEntry, + SpawnConfigSnapshot, TrackedSpawnState, +}; +use crate::managed_agents::AcpSessionPolicy; + +/// Build the prospective snapshot for a bare record under one session policy. +fn snapshot_under(policy: AcpSessionPolicy) -> SpawnConfigSnapshot { + prospective_spawn_config_snapshot( + &record(), + &[], + &[], + "wss://ws.example", + &Default::default(), + false, + policy, + ) +} + +/// Restart-badge entries for a stamped→current session-policy transition, +/// exercising the real badge path (`eligible_restart_diff`). +fn policy_transition_diff( + stamped: &SpawnConfigSnapshot, + current: &SpawnConfigSnapshot, +) -> Vec { + eligible_restart_diff( + false, + Some(TrackedSpawnState { + stamped, + current, + stamped_availability: None, + current_availability: None, + }), + ) +} + +#[test] +fn toggling_session_policy_while_running_requires_restart() { + // Regression: flipping the desktop experiment must reach the config-drift + // path so a running agent restarts onto the new policy. The harness reads + // BUZZ_ACP_SESSION_POLICY only at launch, so without the snapshot field the + // badge stayed dark and the process silently kept the old policy. + let channel = snapshot_under(AcpSessionPolicy::Channel); + let thread = snapshot_under(AcpSessionPolicy::Thread); + + // channel -> thread lights exactly the session_policy entry. + let forward = policy_transition_diff(&channel, &thread); + assert_eq!( + forward.iter().map(|e| e.field.as_str()).collect::>(), + vec!["session_policy"], + ); + + // thread -> channel is equally visible (rollback also restarts). + let reverse = policy_transition_diff(&thread, &channel); + assert_eq!( + reverse.iter().map(|e| e.field.as_str()).collect::>(), + vec!["session_policy"], + ); +} + +#[test] +fn unchanged_session_policy_does_not_require_restart() { + // An unchanged policy must not badge — the default (channel) case must stay + // byte-for-byte inert so existing running agents don't flash a spurious + // restart badge after this change ships. + let channel = snapshot_under(AcpSessionPolicy::Channel); + assert!( + policy_transition_diff(&channel, &snapshot_under(AcpSessionPolicy::Channel)).is_empty() + ); + + let thread = snapshot_under(AcpSessionPolicy::Thread); + assert!(policy_transition_diff(&thread, &snapshot_under(AcpSessionPolicy::Thread)).is_empty()); +} diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac3..d39fcf41009 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `managed_agents/storage.rs`. //! -//! Kept in a sibling file so `storage.rs` stays closer to the 1000-line gate; +//! Kept in a sibling file so `storage.rs` stays closer to the 1500-line gate; //! `#[path]`-included from there. use std::cell::RefCell; diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs index e0ae5fc37aa..8f9d68245de 100644 --- a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs @@ -7,6 +7,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: Some("goose".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 32fe39531d5..fdeb54c4f27 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -254,6 +254,7 @@ mod tests { /// Build a minimal `ManagedAgentRecord` for use as a team member. fn agent_record(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: format!("{name}-pubkey"), name: name.to_string(), display_name: Some(format!("{name} Display")), diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 98816a07e33..fc6f0f1a97b 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `managed_agents/teams.rs`. //! -//! Kept in a sibling file so `teams.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `teams.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::{ @@ -167,6 +167,7 @@ fn validate_team_deletion_rejects_built_ins() { fn managed_agent(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: name.to_string(), name: name.to_string(), persona_id: None, @@ -455,6 +456,7 @@ fn catalog_copy(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: String::new(), runtime: None, @@ -694,6 +696,7 @@ fn catalog_persona(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 7d4b43f01d8..2620f0337fc 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -17,6 +17,11 @@ pub struct AgentDefinition { pub id: String, pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars), shown on the + /// agent's card/profile and carried on the public kind:30175 persona + /// event. EXCLUDED from `persona_content_hash` (no restart badge). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, pub system_prompt: String, /// Preferred ACP runtime ID (e.g., 'goose', 'claude', 'codex'). Determines which agent binary /// Buzz spawns. When deploying from this persona, this runtime is pre-selected in the UI. @@ -146,6 +151,7 @@ impl AgentDefinition { respond_to: RespondTo::default(), respond_to_allowlist: Vec::new(), display_name: Some(self.display_name), + description: self.description, slug: Some(self.id), runtime: self.runtime, name_pool: self.name_pool, @@ -180,6 +186,7 @@ impl ManagedAgentRecord { .clone() .unwrap_or_else(|| self.name.clone()), avatar_url: self.avatar_url.clone(), + description: self.description.clone(), system_prompt: self.system_prompt.clone().unwrap_or_default(), runtime: self.runtime.clone(), model: self.model.clone(), @@ -366,6 +373,13 @@ pub struct ManagedAgentRecord { /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, + /// Optional short, PUBLIC agent description. Keyless definition records + /// carry the authored value; persona-linked instances leave it absent and + /// resolve through their definition so a second copy cannot drift. + /// Display metadata only (never spawn-relevant, never part of the persona + /// content hash). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, /// Stable definition slug — the former `AgentDefinition.id`. Key-less /// records (definitions not yet instantiated) publish kind:30175 at /// `d_tag = slug`, preserving the pre-merge event coordinates. `None` for @@ -452,8 +466,14 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, - /// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn - /// so the harness applies it via `session/set_config_option` at session creation. + /// Canonical, harness-agnostic startup effort level. This is the single + /// persisted effort authority: at spawn the launch projection + /// (`config_bridge::effort`) resolves the effective value over this column + /// and all env tiers, then emits it under the destination runtime's native + /// key — `GOOSE_THINKING_EFFORT` for Goose, `BUZZ_AGENT_THINKING_EFFORT` for + /// buzz-agent, or the `BUZZ_ACP_EFFORT_LEVEL` startup sentinel for + /// Claude/Codex and keyless/unknown adapters. Preserved across runtime + /// switches (invalid values skip-as-absent at projection time). #[serde(default, skip_serializing_if = "Option::is_none")] pub effort_level: Option, } @@ -642,6 +662,18 @@ pub struct AcpRuntimeCatalogEntry { pub provider_env_var: Option, /// Environment variable used to apply thinking effort, when supported. pub thinking_env_var: Option, + /// Canonical accepted effort values for this runtime, in display order. + /// Serialized from `KnownAcpRuntime::effort_normalization.canonical` for + /// runtimes with a static finite vocabulary (e.g. Goose). `None` for + /// runtimes with no canonicalization contract (buzz-agent uses a + /// provider/model catalog; Claude/Codex/unknown runtimes accept any string). + /// + /// The renderer uses this to drive choices and validation, replacing the + /// TS-side `GOOSE_EFFORT_CANONICAL_VALUES` duplicate. When non-null, the + /// `harnessNative` effort field uses this list exclusively — `off` and all + /// other valid Goose values are always present when this is Goose, so + /// `useEffortAutoClear` never incorrectly deletes a valid saved value. + pub effort_canonical_values: Option>, pub max_tokens_env_var: Option, pub context_limit_env_var: Option, pub max_rounds_env_var: Option, diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 3e1afff2561..824ca4ccf3a 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -76,6 +76,9 @@ pub fn apply_persona_behavior( pub struct CreatePersonaRequest { pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars). + #[serde(default)] + pub description: Option, pub system_prompt: String, #[serde(default)] pub runtime: Option, @@ -103,6 +106,10 @@ pub struct UpdatePersonaRequest { pub id: String, pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars). The dialog always + /// sends the current value, so absent and empty both clear it. + #[serde(default)] + pub description: Option, pub system_prompt: String, #[serde(default)] pub runtime: Option, @@ -253,6 +260,16 @@ pub struct UpdateManagedAgentRequest { /// normalized server-side). #[serde(default)] pub respond_to_allowlist: Option>, + /// Absent = don't touch. `null` = clear the canonical effort column + /// (revert to inherited default). `"value"` = set the column. + /// + /// When present, persisted inside the locked update/restart transaction + /// so that an access-policy-change restart snapshots and launches the new + /// effort value rather than the old one. Uses the same + /// `apply_picker_effort_level` logic (via `apply_effort_update`) so + /// the record-scope alias sweep runs atomically with the column write. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub effort_level: Option>, } #[cfg(test)] @@ -269,6 +286,7 @@ mod tests { fn record_without_quad() -> AgentDefinition { AgentDefinition { + description: None, id: "p-1".to_string(), display_name: "Test".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 5299eb4ecca..0918ab2c65c 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -487,6 +487,7 @@ fn sample_agent_record() -> ManagedAgentRecord { fn sample_persona() -> AgentDefinition { AgentDefinition { + description: None, id: "custom:helper".to_string(), display_name: "Helper".to_string(), avatar_url: Some("https://example.com/a.png".to_string()), diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 1e22d7aaeca..9b105e94d4f 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -129,10 +129,9 @@ pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) { } fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { - // Initialize the process-lifetime nest directory before any filesystem - // operation that calls nest_dir(). The discriminator matches the existing - // pattern used by reconcile_target_dir: dev instances have an app-data-dir - // name starting with CANONICAL_DEV_IDENTIFIER. + // Initialize the process-lifetime nest directory before filesystem access + // that calls nest_dir(). The discriminator matches reconcile_target_dir: + // dev instances have an app-data-dir name starting with CANONICAL_DEV_IDENTIFIER. let is_dev = if let Ok(data_dir) = app.path().app_data_dir() { let dev = data_dir .file_name() @@ -144,18 +143,18 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { false }; - // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev BEFORE - // control returns to lib.rs where resolve_repos_at_boot() reads it. This - // ensures the dev nest boots with the correct workspace on its first launch, - // matching what the prod nest had configured. Skip-if-dest-exists so it is - // idempotent and never clobbers a value the dev nest already set explicitly. - // Uses the composed helper so gate + migration share the tested code path. + // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev before + // resolve_repos_at_boot() reads it. Skip-if-dest-exists so it is idempotent + // and never clobbers a value the dev nest already set explicitly. + // The composed helper keeps gate + migration on the tested code path. if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } - migrate_legacy_app_data_dir(app); - sync_shared_agent_data(app); + if !crate::build_identity::is_demo_build() { + migrate_legacy_app_data_dir(app); + sync_shared_agent_data(app); + } // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") // so existing agents don't lose their keys after the service-name split. diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index 754a40769c1..eeb8e68cbeb 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -138,6 +138,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -155,6 +156,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); assert_eq!( @@ -190,6 +192,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -207,6 +210,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); assert_eq!(before.canonical(), after.canonical()); diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 5bc8a6e432c..2573ce2d566 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -25,6 +25,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati }, ]; let definition = crate::managed_agents::AgentDefinition { + description: None, id: "builtin:fizz".to_string(), display_name: "Fizz".to_string(), avatar_url: Some(old_fizz.to_string()), diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 64c8df05a79..51f648769d3 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -57,30 +57,47 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator Option { - let target_hex = event.pubkey.to_hex(); - let Ok(target_pubkey) = nostr::PublicKey::from_hex(&target_hex) else { + if event.kind != nostr::Kind::Metadata { return None; - }; + } - for tag in event.tags.iter() { - let slice = tag.as_slice(); - if slice.first().map(String::as_str) != Some("auth") || slice.len() != 4 { - continue; - } - let Ok(json) = serde_json::to_string(slice) else { - continue; - }; - if let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey) { - return Some(owner_pubkey.to_hex()); - } + let mut auth_tags = tags_named(event, "auth"); + let auth_tag = auth_tags.next()?; + // Count malformed auth tags too: no first-valid-tag fallback is permitted. + if auth_tags.next().is_some() { + return None; } - None + let json = serde_json::to_string(auth_tag).ok()?; + // The structural parser also enforces canonical lowercase key/signature hex. + buzz_sdk_pkg::nip_oa::parse_auth_tag(&json).ok()?; + event.verify().ok()?; + let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &event.pubkey).ok()?; + let conditions = auth_tag.get(2)?; + // Syntax/ranges were checked by the SDK; evaluate every signed clause as-is. + let applies = conditions.is_empty() + || conditions.split('&').all(|clause| { + if let Some(value) = clause.strip_prefix("kind=") { + value.parse::() == Ok(event.kind.as_u16()) + } else if let Some(value) = clause.strip_prefix("created_at<") { + value + .parse::() + .is_ok_and(|bound| event.created_at.as_secs() < bound) + } else if let Some(value) = clause.strip_prefix("created_at>") { + value + .parse::() + .is_ok_and(|bound| event.created_at.as_secs() > bound) + } else { + false + } + }); + + applies.then(|| owner.to_hex()) } pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool { @@ -588,3 +605,6 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests; + +#[cfg(test)] +mod oa_profile_tests; diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs index 28604de5e5f..1429efa4fa6 100644 --- a/desktop/src-tauri/src/nostr_convert/agent_directory.rs +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -14,6 +14,7 @@ use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, pub fn managed_agent_pubkeys_from_events(events: &[Event]) -> std::collections::HashSet { events .iter() + .filter(|event| event.kind == nostr::Kind::Custom(30177) && event.verify().is_ok()) .filter_map(|event| first_tag_value(event, "d")) .filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok()) .map(|pubkey| pubkey.to_hex()) @@ -40,9 +41,25 @@ fn relay_agents_from_legacy_events(events: &[Event]) -> Vec { latest .into_values() .filter_map(|event| { + if event.kind != nostr::Kind::Custom(10100) || event.verify().is_err() { + return None; + } let value = agents_from_events(std::slice::from_ref(event)); let mut agent: RelayAgentInfo = serde_json::from_value(value.get("agents")?.as_array()?.first()?.clone()).ok()?; + // The generic converter defaults missing status to offline for + // compatibility. Discovery must retain only explicit, known runtime + // evidence from this verified latest event, never that fallback. + agent.status = serde_json::from_str::(&event.content) + .ok() + .and_then(|content| { + content + .get("status")? + .as_str() + .filter(|status| matches!(*status, "online" | "away" | "offline")) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()); // Legacy directory entries are not authenticated managed-policy // coordinates, so they must not drive the live 30177 watcher. agent.owner_pubkey = None; @@ -67,11 +84,15 @@ pub fn relay_agents_from_directory_events( .into_iter() .map(|agent| (agent.pubkey.clone(), agent)) .collect(); - for agent_pubkey in verified_policies.keys() { - agents.remove(agent_pubkey); - } for (agent_pubkey, event) in verified_policies { - if let Some(agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + // Remove even when policy parsing fails: invalid latest policy must not + // revive runtime permissions. Only verified runtime liveness survives + // a valid policy overlay; ownership, permissions and membership do not. + let runtime = agents.remove(&agent_pubkey); + if let Some(mut agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + if let Some(runtime) = runtime { + agent.status = runtime.status; + } agents.insert(agent_pubkey, agent); } } @@ -96,6 +117,9 @@ pub fn verified_agent_owners_from_profiles(events: &[Event]) -> HashMap( } fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option { + // Check the envelope as well as the declared author. Keep invalid latest + // coordinates reserved above so they cannot revive older legacy permissions. + if event.kind != nostr::Kind::Custom(30177) || event.verify().is_err() { + return None; + } let content = managed_agent_content_from_event(event).ok()?; Some(RelayAgentInfo { pubkey: agent_pubkey.to_string(), @@ -135,7 +164,8 @@ fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option< channels: Vec::new(), channel_ids: Vec::new(), capabilities: Vec::new(), - status: "offline".to_string(), + // Ownership/policy proves discovery, not conversational liveness. + status: "unknown".to_string(), respond_to: Some(content.respond_to), respond_to_allowlist: content.respond_to_allowlist, }) @@ -157,28 +187,48 @@ pub fn relay_agents_from_managed_agent_events( } /// Build a pubkey-to-channel-id candidate map from relay-signed membership -/// events. Only p-tags explicitly marked with the `bot` role are agents. +/// events. Known agent identities need not have the cosmetic `bot` role; +/// otherwise only explicit bot tags seed discovery. pub fn member_agent_channel_ids_from_events( events: &[Event], relay_pubkey: &str, + known_agent_pubkeys: &std::collections::HashSet, ) -> HashMap> { - let mut channel_ids: HashMap> = HashMap::new(); + let mut latest: HashMap = HashMap::new(); for event in events { - if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) { + if event.kind != nostr::Kind::Custom(39002) + || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) + || event.verify().is_err() + { continue; } let Some(channel_id) = first_tag_value(event, "d") else { continue; }; + if latest + .get(channel_id) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest.insert(channel_id.to_string(), event); + } + } + let mut channel_ids: HashMap> = HashMap::new(); + for (channel_id, event) in latest { for tag in tags_named(event, "p") { - let (Some(pubkey), Some(role)) = (tag.get(1), tag.get(3)) else { + let Some(pubkey) = tag + .get(1) + .and_then(|key| nostr::PublicKey::from_hex(key).ok()) + else { continue; }; - if role != "bot" || nostr::PublicKey::from_hex(pubkey).is_err() { + let pubkey = pubkey.to_hex(); + if tag.get(3).map(String::as_str) != Some("bot") + && !known_agent_pubkeys.contains(&pubkey) + { continue; } channel_ids - .entry(pubkey.clone()) + .entry(pubkey) .or_default() .insert(channel_id.to_string()); } diff --git a/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs b/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs new file mode 100644 index 00000000000..0e031b70b52 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs @@ -0,0 +1,216 @@ +//! NIP-OA profile regressions. All keys, timestamps and Schnorr nonces are +//! synthetic and fixed; these fixtures require no clock, RNG, relay or config. + +use nostr::hashes::{sha256, Hash}; +use nostr::secp256k1::{schnorr::Signature, Keypair, Message}; +use nostr::{Event, EventBuilder, Keys, Kind, SecretKey, Tag, Timestamp, SECP256K1}; + +use super::{ + profile_has_valid_oa_owner, profile_info_from_event, profile_valid_oa_owner_pubkey, tags_named, + user_search_result_from_event, users_batch_from_events, verified_agent_owners_from_profiles, +}; + +const CREATED_AT: u64 = 1_700_000_000; + +// Public test scalars, matching the owner/agent identities in NIP-OA's vectors. +fn keys(scalar: u8) -> Keys { + let mut bytes = [0; 32]; + bytes[31] = scalar; + Keys::new(SecretKey::from_slice(&bytes).unwrap()) +} + +fn sign(keys: &Keys, message: Message) -> Signature { + let keypair = Keypair::from_secret_key(SECP256K1, keys.secret_key()); + SECP256K1.sign_schnorr_no_aux_rand(&message, &keypair) +} + +// Intentionally bypass the SDK's *creation* validation so malformed conditions +// and self-attestation can have genuine signatures and exercise verification. +fn auth_tag_for(owner: &Keys, agent: &Keys, conditions: &str) -> Tag { + let preimage = format!( + "nostr:agent-auth:{}:{conditions}", + agent.public_key().to_hex() + ); + let digest = sha256::Hash::hash(preimage.as_bytes()).to_byte_array(); + let signature = sign(owner, Message::from_digest(digest)); + Tag::parse(vec![ + "auth".to_string(), + owner.public_key().to_hex(), + conditions.to_string(), + signature.to_string(), + ]) + .unwrap() +} + +fn auth_tag(conditions: &str) -> Tag { + auth_tag_for(&keys(1), &keys(2), conditions) +} + +fn event(kind: Kind, created_at: u64, tags: Vec) -> Event { + let agent = keys(2); + let mut unsigned = EventBuilder::new(kind, r#"{"display_name":"Synthetic agent"}"#) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .build(agent.public_key()); + let signature = sign(&agent, Message::from_digest(unsigned.id().to_bytes())); + unsigned.add_signature(signature).unwrap() +} + +fn profile(tags: Vec) -> Event { + event(Kind::Metadata, CREATED_AT, tags) +} + +fn assert_ownership(event: &Event, expected: Option) { + assert_eq!(profile_valid_oa_owner_pubkey(event), expected); + assert_eq!(profile_has_valid_oa_owner(event), expected.is_some()); + + let info = profile_info_from_event(event).unwrap(); + assert_eq!(info.owner_pubkey, expected); + assert_eq!(info.pubkey, event.pubkey.to_hex()); + let search = user_search_result_from_event(event); + assert_eq!(search.owner_pubkey, expected); + assert_eq!(search.is_agent, expected.is_some()); + assert_eq!(search.pubkey, event.pubkey.to_hex()); + let pubkey = event.pubkey.to_hex(); + let batch = users_batch_from_events(std::slice::from_ref(event), std::slice::from_ref(&pubkey)); + assert_eq!(batch.profiles[&pubkey].owner_pubkey, expected); + assert_eq!(batch.profiles[&pubkey].is_agent, expected.is_some()); + let owners = verified_agent_owners_from_profiles(std::slice::from_ref(event)); + assert_eq!(owners.get(&pubkey), expected.as_ref()); +} + +#[test] +fn accepts_unconditional_and_applicable_conditional_ownership() { + for conditions in [ + "", + "kind=0", + "created_at>1699999999&kind=0&created_at<1700000001", + "created_at<1700000001&created_at>1699999999&kind=0&kind=0", + ] { + let tag = auth_tag(conditions); + // Check that deterministic fixture signing agrees with the SDK verifier. + let json = serde_json::to_string(tag.as_slice()).unwrap(); + assert_eq!( + buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &keys(2).public_key()).unwrap(), + keys(1).public_key() + ); + assert_ownership(&profile(vec![tag]), Some(keys(1).public_key().to_hex())); + } +} + +#[test] +fn rejects_duplicate_auth_tags_including_malformed_tags_in_either_order() { + let valid = auth_tag(""); + let malformed = Tag::parse(["auth"]).unwrap(); + for tags in [ + vec![valid.clone(), valid.clone()], + vec![valid.clone(), auth_tag("kind=0")], + vec![valid.clone(), malformed.clone()], + vec![malformed, valid], + ] { + let event = profile(tags); + assert_eq!(tags_named(&event, "auth").count(), 2); + assert_ownership(&event, None); + } +} + +#[test] +fn rejects_wrong_kind_condition_and_conflicting_clauses() { + for conditions in ["kind=1", "kind=0&kind=1", "kind=1&kind=0"] { + assert_ownership(&profile(vec![auth_tag(conditions)]), None); + } +} + +#[test] +fn time_bounds_are_strict_and_use_event_time_not_wall_clock() { + let tag = auth_tag("created_at>1699999999&created_at<1700000001"); + for (timestamp, accepted) in [ + (1_699_999_998, false), + (1_699_999_999, false), + (CREATED_AT, true), + (1_700_000_001, false), + (1_700_000_002, false), + (u64::from(u32::MAX) + 1, false), + ] { + let event = event(Kind::Metadata, timestamp, vec![tag.clone()]); + let expected = accepted.then(|| keys(1).public_key().to_hex()); + assert_ownership(&event, expected); + } +} + +#[test] +fn rejects_malformed_tag_shapes_and_hex() { + let valid = auth_tag("").as_slice().to_vec(); + let mut extra = valid.clone(); + extra.push("extra".to_string()); + let mut bad_owner = valid.clone(); + bad_owner[1] = "not-a-pubkey".to_string(); + let mut uppercase_owner = valid.clone(); + uppercase_owner[1] = uppercase_owner[1].to_uppercase(); + let mut uppercase_signature = valid.clone(); + uppercase_signature[3] = uppercase_signature[3].to_uppercase(); + let mut bad_signature = valid.clone(); + bad_signature[3] = "00".repeat(64); + for values in [ + vec!["auth".to_string()], + valid[..3].to_vec(), + extra, + bad_owner, + uppercase_owner, + uppercase_signature, + bad_signature, + ] { + assert_ownership(&profile(vec![Tag::parse(values).unwrap()]), None); + } +} + +#[test] +fn rejects_signed_but_malformed_conditions() { + for conditions in [ + "kind=0&", + "&kind=0", + "kind=0&&kind=0", + "kind=00", + "kind=65536", + "kind=0 ", + "kind=٠", + "Kind=0", + "created_at=1700000000", + "created_at<4294967296", + "created_at>-1", + "unsupported=0", + ] { + assert_ownership(&profile(vec![auth_tag(conditions)]), None); + } +} + +#[test] +fn rejects_absent_authority_self_attestation_and_wrong_agent_binding() { + assert_ownership(&profile(vec![]), None); + assert_ownership( + &profile(vec![ + Tag::parse(["owner", &keys(1).public_key().to_hex()]).unwrap() + ]), + None, + ); + assert_ownership(&profile(vec![auth_tag_for(&keys(2), &keys(2), "")]), None); + assert_ownership(&profile(vec![auth_tag_for(&keys(1), &keys(3), "")]), None); +} + +#[test] +fn rejects_non_profile_and_invalid_event_even_with_valid_auth_tag() { + assert_ownership(&event(Kind::TextNote, CREATED_AT, vec![auth_tag("")]), None); + + let mut wrong_id = profile(vec![auth_tag("")]); + wrong_id.content = r#"{"display_name":"Tampered"}"#.to_string(); + assert!(wrong_id.verify().is_err()); + assert_ownership(&wrong_id, None); + + let mut wrong_signature = profile(vec![auth_tag("")]); + wrong_signature.sig = sign( + &keys(3), + Message::from_digest(wrong_signature.id.to_bytes()), + ); + assert!(wrong_signature.verify().is_err()); + assert_ownership(&wrong_signature, None); +} diff --git a/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs new file mode 100644 index 00000000000..dd0dda7f11e --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs @@ -0,0 +1,143 @@ +//! Bind availability provenance to the production runtime/policy merge. + +use super::*; + +fn fixture() -> (Keys, Event, Event) { + let owner = Keys::generate(); + let agent = Keys::generate(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("compute ownership"); + let values: Vec = serde_json::from_str(&auth).expect("parse ownership"); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(values).expect("ownership tag")]) + .sign_with_keys(&agent) + .expect("sign identity"); + let policy = managed_agent_event( + &owner, + &agent.public_key().to_hex(), + "Policy name", + "allowlist", + &["a".repeat(64)], + ); + (agent, profile, policy) +} + +fn runtime(keys: &Keys, status: Option, timestamp: u64) -> Event { + let mut content = serde_json::json!({ + "name": "Runtime name", + "owner_pubkey": "b".repeat(64), + "respond_to": "anyone", + "respond_to_allowlist": ["b".repeat(64)], + "channels": ["Untrusted"], + "channel_ids": ["untrusted-channel"], + "capabilities": ["untrusted-capability"] + }); + if let Some(status) = status { + content["status"] = status; + } + EventBuilder::new(Kind::Custom(10100), content.to_string()) + .custom_created_at(nostr::Timestamp::from(timestamp)) + .sign_with_keys(keys) + .expect("sign runtime") +} + +fn assert_merge(directory: &[Event], profile: &Event, policy: &Event, status: &str) { + let merged = relay_agents_from_directory_events( + directory, + std::slice::from_ref(policy), + std::slice::from_ref(profile), + ); + assert_eq!(merged.len(), 1); + let agent = &merged[0]; + assert_eq!(agent.status, status); + assert_eq!(serde_json::to_value(agent).unwrap()["status"], status); + assert_eq!(agent.pubkey, profile.pubkey.to_hex()); + assert_eq!(agent.owner_pubkey, Some(policy.pubkey.to_hex())); + assert_eq!(agent.name, "Policy name"); + assert_eq!( + agent.respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(agent.respond_to_allowlist, vec!["a".repeat(64)]); + assert!( + agent.channel_ids.is_empty(), + "runtime cannot grant membership" + ); + assert!(agent.channels.is_empty()); + assert!(agent.capabilities.is_empty()); +} + +fn assert_known_status(status: &str) { + let (keys, profile, policy) = fixture(); + assert_merge( + &[runtime(&keys, Some(json!(status)), 10)], + &profile, + &policy, + status, + ); +} + +#[test] +fn policy_preserves_signed_online_runtime() { + assert_known_status("online"); +} + +#[test] +fn policy_preserves_signed_away_runtime() { + assert_known_status("away"); +} + +#[test] +fn policy_preserves_signed_offline_runtime() { + assert_known_status("offline"); +} + +#[test] +fn missing_or_unrecognized_runtime_status_is_unknown() { + let (keys, profile, policy) = fixture(); + for status in [ + None, + Some(Value::Null), + Some(json!(42)), + Some(json!("busy")), + Some(json!("unknown")), + ] { + let directory = runtime(&keys, status, 10); + assert_merge( + std::slice::from_ref(&directory), + &profile, + &policy, + "unknown", + ); + let legacy = relay_agents_from_directory_events(&[directory], &[], &[]); + assert_eq!(legacy[0].status, "unknown", "no default offline evidence"); + } +} + +#[test] +fn policy_only_has_unknown_availability() { + let (_, profile, policy) = fixture(); + assert_merge(&[], &profile, &policy, "unknown"); +} + +#[test] +fn latest_runtime_without_status_does_not_revive_older_online_status() { + let (keys, profile, policy) = fixture(); + let online = runtime(&keys, Some(json!("online")), 10); + let missing = runtime(&keys, None, 20); + for directory in [[online.clone(), missing.clone()], [missing, online]] { + assert_merge(&directory, &profile, &policy, "unknown"); + } +} + +#[test] +fn forged_latest_runtime_cannot_supply_or_revive_availability() { + let (keys, profile, policy) = fixture(); + let old = runtime(&keys, Some(json!("online")), 10); + let new = runtime(&keys, Some(json!("away")), 20); + let mut value = serde_json::to_value(new).unwrap(); + value["content"] = json!(r#"{"status":"online"}"#); + let forged: Event = serde_json::from_value(value).unwrap(); + assert!(forged.verify().is_err()); + assert_merge(&[old, forged], &profile, &policy, "unknown"); +} diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 9401d19add4..68d8cb7dcbb 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -3,6 +3,9 @@ use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; +#[path = "runtime_policy_tests.rs"] +mod runtime_policy_tests; + /// Build a signed event for testing with the given kind, content, and tags. fn ev(kind: u16, content: &str, tags: Vec>) -> Event { let keys = Keys::generate(); @@ -437,6 +440,11 @@ fn managed_agent_directory_accepts_only_the_verified_owner_policy() { assert_eq!(agents.len(), 1); assert_eq!(agents[0].pubkey, agent_pubkey); assert_eq!(agents[0].name, "Codex"); + assert_eq!(agents[0].status, "unknown"); + assert_eq!( + serde_json::to_value(&agents[0]).unwrap()["status"], + "unknown" + ); assert_eq!( agents[0].respond_to, Some(crate::managed_agents::RespondTo::Allowlist) @@ -510,8 +518,11 @@ fn managed_agent_candidates_use_only_relay_signed_bot_membership() { vec![vec!["d", "forged"], vec!["p", &agent_pubkey, "", "bot"]], ); - let channel_ids = - member_agent_channel_ids_from_events(&[forged, general], &relay_keys.public_key().to_hex()); + let channel_ids = member_agent_channel_ids_from_events( + &[forged, general], + &relay_keys.public_key().to_hex(), + &Default::default(), + ); assert_eq!( channel_ids.get(&agent_pubkey), @@ -760,3 +771,62 @@ fn timestamp_to_iso_known_value() { // Epoch assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); } + +#[test] +fn known_owned_agents_have_membership_independent_of_role() { + let relay = Keys::generate(); + let agent = Keys::generate().public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &agent, "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + let memberships = member_agent_channel_ids_from_events( + &[event], + &relay.public_key().to_hex(), + &std::collections::HashSet::from([agent.clone()]), + ); + assert_eq!(memberships.get(&agent), Some(&vec!["general".to_string()])); +} + +#[test] +fn managed_directory_rejects_tampered_event_envelopes() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let auth: Vec = serde_json::from_str(&auth).unwrap(); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(auth).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + let policy = managed_agent_event( + &owner, + &agent.public_key().to_hex(), + "Scout", + "owner-only", + &[], + ); + let tamper = |event: &Event, content: &str| -> Event { + let mut value = serde_json::to_value(event).unwrap(); + value["content"] = serde_json::json!(content); + serde_json::from_value(value).unwrap() + }; + let forged_policy = tamper( + &policy, + r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#, + ); + assert!(forged_policy.verify().is_err()); + assert!( + relay_agents_from_managed_agent_events(&[forged_policy], std::slice::from_ref(&profile),) + .is_empty(), + "an owner pubkey string is not an owner signature" + ); + let forged_profile = tamper(&profile, r#"{"name":"forged"}"#); + assert!(forged_profile.verify().is_err()); + assert!( + relay_agents_from_managed_agent_events(&[policy], &[forged_profile],).is_empty(), + "a valid OA tag does not authenticate the profile envelope" + ); +} diff --git a/desktop/src-tauri/src/persona_catalog.rs b/desktop/src-tauri/src/persona_catalog.rs index 5d1717d67c3..c04afb64b4c 100644 --- a/desktop/src-tauri/src/persona_catalog.rs +++ b/desktop/src-tauri/src/persona_catalog.rs @@ -16,7 +16,8 @@ use std::sync::LazyLock; use tauri::State; use crate::{ - app_state::AppState, managed_agents::validate_agent_definition_text, + app_state::AppState, + managed_agents::{validate_agent_definition_text, validate_agent_description_text}, native_relay_client::NativeRelayClient, }; @@ -47,6 +48,8 @@ pub(crate) struct PersonaCatalogPublication { struct CatalogAgentProjection { display_name: String, avatar_url: Option, + /// Optional public description (max 280 chars, visible-text policy). + description: Option, system_prompt: String, runtime: Option, model: Option, @@ -223,6 +226,16 @@ fn parse_agent(content: &str) -> Option { .unwrap_or_default() .to_string(); validate_agent_definition_text(&display_name, &system_prompt).ok()?; + // Untrusted boundary: a description that fails the shared 280-char + + // visible-text policy rejects the whole entry rather than being stripped, + // matching how the other definition fields are handled. + let raw_description = match object.get("description") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => return None, + }; + validate_agent_description_text(raw_description.as_deref()).ok()?; + let description = raw_description.filter(|value| !value.trim().is_empty()); let respond_to = match object.get("respond_to").and_then(Value::as_str) { Some("allowlist") => Some("owner-only".to_string()), @@ -252,6 +265,7 @@ fn parse_agent(content: &str) -> Option { .and_then(Value::as_str) .filter(|value| safe_avatar(value)) .map(ToOwned::to_owned), + description, system_prompt, runtime: optional_string(object.get("runtime")), model: optional_string(object.get("model")), diff --git a/desktop/src-tauri/src/persona_catalog_tests.rs b/desktop/src-tauri/src/persona_catalog_tests.rs index d3175ef9807..64cb1ce2114 100644 --- a/desktop/src-tauri/src/persona_catalog_tests.rs +++ b/desktop/src-tauri/src/persona_catalog_tests.rs @@ -127,6 +127,31 @@ fn parser_rejects_malformed_and_invisible_definition_text() { ] { assert!(parse_agent(&content).is_none()); } + // A description that violates the shared visible-text policy or the + // 280-char cap rejects the whole entry — never silently stripped. + for bad_description in [ + "hidden\u{200b}text".to_string(), + "description\n".to_string(), + "a".repeat(281), + ] { + let mut content = valid_content("Reviewer"); + content["description"] = json!(bad_description); + assert!(parse_agent(&content.to_string()).is_none()); + } + for malformed_description in [json!(7), json!([]), json!({})] { + let mut content = valid_content("Reviewer"); + content["description"] = malformed_description; + assert!(parse_agent(&content.to_string()).is_none()); + } + let mut content = valid_content("Reviewer"); + content["description"] = json!("A careful reviewer."); + assert_eq!( + parse_agent(&content.to_string()) + .unwrap() + .description + .as_deref(), + Some("A careful reviewer.") + ); let visible = parse_agent( &json!({ "display_name": "Reviewer 🐝", @@ -204,6 +229,7 @@ fn serialized_catalog_matches_the_typescript_contract() { agent: CatalogAgentProjection { display_name: "Ada".into(), avatar_url: Some("https://example.com/a.png".into()), + description: Some("A kind agent.".into()), system_prompt: "be kind".into(), runtime: Some("acp".into()), model: Some("m1".into()), @@ -222,6 +248,7 @@ fn serialized_catalog_matches_the_typescript_contract() { "agent": { "displayName": "Ada", "avatarUrl": "https://example.com/a.png", + "description": "A kind agent.", "systemPrompt": "be kind", "runtime": "acp", "model": "m1", diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f408ef2afda..676b9656ff2 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -477,9 +477,10 @@ fn build_profile_event( agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, + about: Option<&str>, auth_tag_json: Option<&str>, ) -> Result { - let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; + let builder = crate::events::build_profile(Some(display_name), None, avatar_url, about, None)?; let builder = if let Some(tag_json) = auth_tag_json { // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding. @@ -511,18 +512,22 @@ fn build_profile_event( /// Sync a managed agent's kind:0 profile event to the relay using NIP-98 auth. /// /// The agent signs its own profile event and the NIP-98 HTTP-auth event, so no -/// API token is required. +/// API token is required. `about` carries the agent's authored public +/// description (see `managed_agents::record_effective_description`); the +/// relay treats kind:0 +/// fields as absolute, so passing `None` clears any previously published about. pub async fn sync_managed_agent_profile( state: &AppState, relay_url: &str, agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, + about: Option<&str>, auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { crate::relay_admission::wait_for_rate_limit().await; // Build a signed kind:0 profile event (with optional NIP-OA auth tag). - let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; + let event = build_profile_event(agent_keys, display_name, avatar_url, about, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; @@ -563,8 +568,9 @@ pub async fn sync_managed_agent_profile( /// backend — always the active workspace relay — so the query targets the host /// the profile is actually published to. /// -/// Returns the parsed profile content (display_name, picture) if a kind:0 event -/// exists for the given pubkey, or `None` if no profile is published. +/// Returns the parsed profile content (display_name, picture, about) if a +/// kind:0 event exists for the given pubkey, or `None` if no profile is +/// published. pub async fn query_agent_profile( state: &AppState, relay_url: &str, @@ -595,6 +601,10 @@ pub async fn query_agent_profile( .get("picture") .and_then(|v| v.as_str()) .map(str::to_string), + about: content + .get("about") + .and_then(|v| v.as_str()) + .map(str::to_string), })) } @@ -603,6 +613,8 @@ pub async fn query_agent_profile( pub struct AgentProfileInfo { pub display_name: Option, pub picture: Option, + /// Published public description (kind:0 `about`). + pub about: Option, } // ── Signed-event submission ───────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs index 4ae39249328..0fcbc891b79 100644 --- a/desktop/src-tauri/src/relay/tests.rs +++ b/desktop/src-tauri/src/relay/tests.rs @@ -569,7 +569,7 @@ fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { fn profile_event_with_valid_auth_tag() { let agent_keys = nostr::Keys::generate(); let tag_json = make_valid_auth_tag(&agent_keys); - let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) + let event = build_profile_event(&agent_keys, "TestBot", None, None, Some(&tag_json)) .expect("should succeed with a valid auth tag"); // Exactly one "auth" tag must be present. @@ -587,7 +587,7 @@ fn profile_event_with_valid_auth_tag() { #[test] fn profile_event_without_auth_tag() { let agent_keys = nostr::Keys::generate(); - let event = build_profile_event(&agent_keys, "TestBot", None, None) + let event = build_profile_event(&agent_keys, "TestBot", None, None, None) .expect("should succeed without an auth tag"); // No "auth" tags should be present. @@ -601,12 +601,41 @@ fn profile_event_without_auth_tag() { assert_eq!(event.kind, nostr::Kind::Metadata); } +#[test] +fn profile_event_includes_about_when_description_present() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event( + &agent_keys, + "TestBot", + None, + Some("A meticulous code reviewer."), + None, + ) + .expect("should succeed with an about"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("kind:0 content is JSON"); + assert_eq!( + content.get("about").and_then(|v| v.as_str()), + Some("A meticulous code reviewer.") + ); +} + +#[test] +fn profile_event_omits_about_when_absent() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event(&agent_keys, "TestBot", None, None, None) + .expect("should succeed without an about"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("kind:0 content is JSON"); + assert!(content.get("about").is_none()); +} + #[test] fn profile_event_rejects_invalid_auth_tag() { let agent_keys = nostr::Keys::generate(); // Structurally valid JSON array but with a bogus signature — verification must fail. let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); - let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); + let result = build_profile_event(&agent_keys, "TestBot", None, None, Some(&bad_json)); assert!(result.is_err(), "should reject an invalid auth tag"); assert!( result.unwrap_err().contains("verification failed"), diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 18ddd80eb8d..401d63c9c49 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -104,6 +104,11 @@ pub(crate) struct ResetContext<'a> { pub keychain: &'a dyn ResetKeychain, pub home_dir: Option, pub is_dev: bool, + /// Build-owned config root for demos. Production leaves this unset. + pub demo_config_dir: Option, + /// Demo builds own only build-scoped state and must never delete shared + /// production or legacy agent roots. + pub is_demo: bool, } /// Entry point called from `lib.rs` setup (before migrations). @@ -126,6 +131,16 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir); let nest_dir = crate::managed_agents::nest_dir(); + let demo_config_dir = match crate::build_identity::demo_config_home() { + Ok(dir) => dir, + Err(error) => { + eprintln!("buzz-desktop reset: {error}"); + return ResetOutcome { + completed: false, + failed: true, + }; + } + }; let ctx = ResetContext { app_data_dir, legacy_app_data_dir: legacy_dir, @@ -133,6 +148,8 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { keychain: &store, home_dir, is_dev, + demo_config_dir, + is_demo: crate::build_identity::is_demo_build(), }; run_boot_reset_with_keychain(ctx) @@ -166,6 +183,15 @@ fn rename_to_trash(src: &Path) -> Result { /// Core wipe logic — separated for testing. pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome { + // An unknown demo credential root is not evidence of an absent root. Refuse + // before any destructive work and retain reset intent for the next boot. + if ctx.is_demo && ctx.demo_config_dir.is_none() { + eprintln!("buzz-desktop reset: cannot resolve demo credential directory"); + return ResetOutcome { + completed: false, + failed: true, + }; + } let app_data_dir = ctx.app_data_dir; // ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ── @@ -211,13 +237,34 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom None }; - // ── Step 3: remove nest, ~/.sprout, ~/.config/buzz-agent, CLI symlink ──── + // ── Step 3: remove build-owned nest and CLI symlink ────────────────────── + // Production and dev preserve their existing legacy/global cleanup. A demo + // never owns these shared roots, so signing out of one must leave them + // available to production and every other demo. if let Some(ref nest) = ctx.nest_dir { let _ = std::fs::remove_dir_all(nest); } + // A demo owns credentials here. Failure to remove them must keep the reset + // pending, even if the app data and keychain were successfully wiped. + let demo_config_removed = + ctx.demo_config_dir + .as_ref() + .is_none_or(|path| match std::fs::remove_dir_all(path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + eprintln!( + "buzz-desktop reset: remove demo config {}: {error}", + path.display() + ); + false + } + }); if let Some(ref home) = ctx.home_dir { - let _ = std::fs::remove_dir_all(home.join(".sprout")); - let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + if !ctx.is_demo { + let _ = std::fs::remove_dir_all(home.join(".sprout")); + let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + } let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); } @@ -273,6 +320,11 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom .map(|p| !p.exists()) .unwrap_or(true); let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true); + // `exists()` treats metadata errors as absence. Only NotFound establishes + // that credentials are gone; a dangling symlink is not an absent root. + let demo_config_gone = ctx.demo_config_dir.as_ref().is_none_or(|path| { + matches!(std::fs::symlink_metadata(path), Err(error) if error.kind() == std::io::ErrorKind::NotFound) + }); let trash_app_gone = !trash_app.exists(); let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true); let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true); @@ -281,6 +333,8 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom || !app_data_gone || !legacy_gone || !nest_gone + || !demo_config_removed + || !demo_config_gone || !trash_app_gone || !trash_legacy_gone || !trash_webkit_gone @@ -288,6 +342,7 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom eprintln!( "buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \ app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \ + demo_config_removed={demo_config_removed}, demo_config_gone={demo_config_gone}, \ trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \ trash_webkit_gone={trash_webkit_gone})" ); @@ -318,6 +373,10 @@ mod tests { use std::cell::Cell; use tempfile::TempDir; + mod demo { + include!("reset_demo_tests.rs"); + } + // ── Fake keychain ───────────────────────────────────────────────────────── struct FakeKeychain { @@ -408,6 +467,8 @@ mod tests { keychain, home_dir: None, // skip nest/sprout/CLI ops in unit tests is_dev, + demo_config_dir: None, + is_demo: false, } } @@ -451,6 +512,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -584,6 +647,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -620,6 +685,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -653,6 +720,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -736,6 +805,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "reset must complete"); @@ -830,6 +901,8 @@ mod tests { keychain: &kc1, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let first = run_boot_reset_with_keychain(ctx1); assert!(first.failed, "first attempt must fail"); @@ -853,6 +926,8 @@ mod tests { keychain: &kc2, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let second = run_boot_reset_with_keychain(ctx2); assert!(second.completed, "second attempt must complete"); diff --git a/desktop/src-tauri/src/reset_demo_tests.rs b/desktop/src-tauri/src/reset_demo_tests.rs new file mode 100644 index 00000000000..9db2ab3dc74 --- /dev/null +++ b/desktop/src-tauri/src/reset_demo_tests.rs @@ -0,0 +1,169 @@ +use super::*; + +#[test] +fn test_demo_reset_preserves_shared_and_other_build_state() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app.demo.current-1234567812345678"); + let demo_nest = home.join(".buzz-demo-current-1234567812345678"); + let prod_nest = home.join(".buzz"); + let other_demo_nest = home.join(".buzz-demo-other-8765432187654321"); + let shared_sprout = home.join(".sprout"); + let shared_agent = home.join(".config").join("buzz-agent"); + let demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-current-1234567812345678"); + let demo_oauth = demo_config.join("buzz-agent").join("oauth"); + let other_demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-other-8765432187654321"); + let other_demo_oauth = other_demo_config.join("buzz-agent").join("oauth"); + + for path in [ + &app_data, + &demo_nest, + &prod_nest, + &other_demo_nest, + &shared_sprout, + &shared_agent, + &demo_oauth, + &other_demo_oauth, + ] { + std::fs::create_dir_all(path).unwrap(); + } + write_sentinel(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(demo_nest.clone()), + keychain: &kc, + home_dir: Some(home), + is_dev: false, + demo_config_dir: Some(demo_config.clone()), + is_demo: true, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "demo reset must complete"); + assert!(!app_data.exists(), "demo app data must be wiped"); + assert!(!demo_nest.exists(), "selected demo nest must be wiped"); + assert!( + !demo_config.exists(), + "selected demo auth root must be wiped" + ); + assert!( + other_demo_oauth.exists(), + "another demo's concrete auth root must survive" + ); + assert!(prod_nest.exists(), "production nest must survive"); + assert!(other_demo_nest.exists(), "another demo nest must survive"); + assert!(shared_sprout.exists(), "shared legacy state must survive"); + assert!( + shared_agent.exists(), + "shared agent auth state must survive" + ); +} + +#[test] +fn demo_config_delete_failure_keeps_sentinel_until_retry() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let production = tmp.path().join("production/oauth/token.json"); + let sibling = tmp.path().join("sibling/oauth/token.json"); + for path in [&production, &sibling] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "preserve").unwrap(); + } + // A file at the directory path makes remove_dir_all fail on every platform, + // independent of the test user's privileges. + std::fs::write(&config, "obstruction").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + let first = run(); + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert!(config.exists()); + + std::fs::remove_file(&config).unwrap(); + let token = config.join("buzz-agent/oauth/databricks/token.json"); + std::fs::create_dir_all(token.parent().unwrap()).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + let second = run(); + assert!(second.completed && !second.failed); + assert!(!check_sentinel(&app_data)); + assert!(!config.exists()); + for path in [&production, &sibling] { + assert_eq!(std::fs::read_to_string(path).unwrap(), "preserve"); + } + // A retry after a crash that already removed the root must also succeed. + write_sentinel(&app_data).unwrap(); + assert!(run().completed); + assert!(!check_sentinel(&app_data)); +} + +#[cfg(unix)] +#[test] +fn demo_oauth_permission_failure_preserves_retry_intent() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let oauth = config.join("buzz-agent/oauth/databricks"); + let token = oauth.join("token.json"); + std::fs::create_dir_all(&oauth).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o500)).unwrap(); + let first = run(); + // Restore permissions before assertions so a failure never leaves test debris. + if oauth.exists() { + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert_eq!(std::fs::read_to_string(&token).unwrap(), "demo credential"); + assert!(run().completed); + assert!(!config.exists()); + assert!(!check_sentinel(&app_data)); +} + +#[test] +fn unresolved_demo_config_keeps_reset_pending_without_deleting_state() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + assert!(ctx.demo_config_dir.is_none()); + let outcome = run_boot_reset_with_keychain(ctx); + assert!(outcome.failed && !outcome.completed); + assert!(check_sentinel(&app_data)); + assert!( + app_data.exists(), + "unresolved root must refuse before wiping" + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eb5ab5a95d8..071cc3b1803 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { ProtectedGlobalOverlay } from "@protected-feature-components"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; @@ -59,6 +60,7 @@ import { import { useSetUserStatusMutation, useUserStatusQuery, + visibleUserStatus, useUserStatusSubscription, } from "@/features/user-status/hooks"; import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; @@ -893,9 +895,7 @@ export function AppShell() { onSetPresenceStatus={(status) => presenceSession.setStatus(status) } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } + onSetUserStatus={setUserStatusMutation.mutate} onClearUserStatus={() => setUserStatusMutation.mutate({ text: "", @@ -908,9 +908,11 @@ export function AppShell() { } selfUserStatus={ deferredPubkey - ? (selfStatusQuery.data?.[ - deferredPubkey.toLowerCase() - ] ?? undefined) + ? (visibleUserStatus( + selfStatusQuery.data?.[ + deferredPubkey.toLowerCase() + ], + ) ?? undefined) : undefined } selectedChannelId={selectedChannelId} @@ -986,6 +988,7 @@ export function AppShell() { onOpenChange={setIsSendFeedbackOpen} open={isSendFeedbackOpen} /> + {!isHuddleRoom ? : null} diff --git a/desktop/src/app/routes/root.tsx b/desktop/src/app/routes/root.tsx index de3441eaaee..65ee8e2d40f 100644 --- a/desktop/src/app/routes/root.tsx +++ b/desktop/src/app/routes/root.tsx @@ -1,7 +1,19 @@ import { createRootRoute } from "@tanstack/react-router"; import { AppShell } from "@/app/AppShell"; +import { HuddlePresenceProvider } from "@/features/huddle/HuddlePresenceContext"; +import { UserStatusLookupProvider } from "@/features/user-status/UserStatusLookupContext"; + +function RootRoute() { + return ( + + + + + + ); +} export const Route = createRootRoute({ - component: AppShell, + component: RootRoute, }); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164db..ff8df71cd30 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -153,8 +153,8 @@ with a TypeScript lookup table or an id comparison in a component. place that resolves it for dialog surfaces and publishes it through `ui/AgentRunLocationContext.tsx`; the field reads that context and lets an explicit `runLocation` prop win. Do **not** thread the value as a prop - through `AgentDefinitionDialog` / `AgentInstanceEditDialog` — both are - already over the 1000-line ceiling, and neither uses the value itself. + through `AgentDefinitionDialog` / `AgentInstanceEditDialog` — neither uses + the value itself, and the shared context keeps the dialog boundary stable. Surfaces rendered outside `AgentDialog` (e.g. `EditRespondToDialog`) pass the prop directly. Local names "your computer, including files, accounts, and connected tools"; remote names "the @@ -200,25 +200,46 @@ with a TypeScript lookup table or an id comparison in a component. agent from Agents, a DM, or a channel must expose the same actions, tabs, fields, and profile-wide activity selection. Caller context may control the panel shell or return navigation, but must not filter or replace profile - content. + content. Explicit public-key targets are always exact, including stopped, + archived, and relay-only identities. Only explicit persona navigation may + select a representative or offer persona Start; a relay persona link cannot + borrow a local sibling's management controls. See + [the identity contract](../../../../docs/agent-profile-identity.md). + Availability dots read relay presence, never a saved deployment + receipt or runtime status. Failed/disconnected reads are unknown; lifecycle + actions retain their separate routing. Current exact-key Online/Away presence + suppresses Start for an inactive local record without granting Stop authority; + list/profile/member startup guards must not interpret Offline as proof of safe + startup. Deletion also consumes that same exact-key availability reader: + unknown requests shutdown when a channel exists, request failure retains the + record, and only established Offline keeps the intentional no-request path. + Unqueried persona siblings are unknown. No presence state grants deletion or + Stop authority; native local stop-before-remove remains independent. See + [the availability contract](../../../../docs/agent-availability.md). + The shared cloud marker means “Not managed on this device” only + after ownership and successful local inventory are known. It does not imply + hosting location, availability, or permission. Keep all identity surfaces on + the shared provenance context, without per-row directory subscriptions. See + [the provenance contract](../../../../docs/agent-management-provenance.md). 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in - `AgentInstanceEditDialog` beside the Model block. It is direct-write, not - part of the frozen `UpdateManagedAgentInput` shape: each selection calls - `persistAgentEffortLevel` and invalidates the config-surface query, mirroring - the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and - option compute live in the pure helper `ui/effortPicker.ts` - (`effortPickerState`): the picker renders only when - `agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId` - has been discovered from the running session (absent pre-first-session and - for runtimes/models without effort support). Local-only is load-bearing, not - cosmetic — the Rust command rejects non-local backends because remote effort - is set at deploy time via `policy_env`. Because it reads its inputs from the - config surface the dialog already fetches (`useAgentConfigSurface`) and owns - its own mutation, it does **not** thread new props through the over-1000-line - dialog (see rule 11): keep effort state inside the section component, never - as dialog-level props. The read-only display is the `thinkingEffort` + `AgentInstanceEditDialog` beside the Model block. It is **Save-gated, not + direct-write**: the control is fully controlled by the parent dialog + (`value`/`onChange`) and owns no mutation. The dialog persists the selection + by embedding `effortLevel` in the locked `update_managed_agent` IPC call, so + the effort write is atomic with any access-policy change and can never race + or survive a Cancel or failed Save. There is no standalone + `persistAgentEffortLevel` setter. Its gating and option compute live in the + pure helper `ui/effortPicker.ts` (`effortPickerState`): the picker renders + only when `agent.backend.type === "local"` **AND** a `thought_level` + `effortConfigId` has been discovered from the running session (absent + pre-first-session and for runtimes/models without effort support). Local-only + is load-bearing, not cosmetic — the Rust command rejects non-local backends + because remote effort is set at deploy time via `policy_env`. Because the + control reads its inputs from the config surface the dialog already fetches + (`useAgentConfigSurface`), it integrates into the dialog's existing field + group without additional IPC. The read-only display is the `thinkingEffort` normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which already shows both facts — `field.value` (canonical, the effort the next spawn will launch with) and, when a running ACP session differs, @@ -236,7 +257,38 @@ with a TypeScript lookup table or an id comparison in a component. mid-conversation effort control without a plan ruling. The archived live-effort machinery lives on `archive/claude-config-gaps-live-effort` for reference only. -12. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** +15. **The persona `description` is public display metadata.** It is optional, + capped at 280 characters, and validated through the shared visible-text + policy (`validate_agent_description_text` in `definition_validation.rs`) + on the raw authored bytes at create/update, snapshot import, publication, + inbound sync, and the untrusted catalog parser — rejected, never stripped. + It is deliberately EXCLUDED from `persona_content_hash` + (`description_change_does_not_change_content_hash`), so a description-only + edit never flips the restart badge on linked instances. Only the AUTHORED + description exists — there is deliberately no derived/generated fallback; + a blank description publishes an empty kind:0 `about`, exactly as before + the field existed. Agent and team snapshots carry the authored description + in the member profile's `about` and validate it before import. The trim/empty + resolution exists twice and must stay in + sync (port changes in the same PR): `lib/agentDescription.ts` + (`effectiveAgentDescription`) feeds display surfaces, and its Rust twin + (`managed_agents/agent_description.rs`, `effective_agent_description` / + `record_effective_description`) feeds the publish path, where + `profile_needs_sync` compares `about` (None == empty) so description edits + reconcile instead of being clobbered. Persona-linked instances do not own a + second description copy; snapshot export materializes the definition value + only into the portable snapshot, and a dangling link resolves no description + rather than reviving stale instance metadata. The agents-page card face shows the + authored description as its second line, falling back to the model label + when none exists (`UnifiedAgentsSection.tsx` composes it; + `AgentIdentityCard` takes a presentational `subtitle`). The community catalog + shows the same authored description before consent: a clamped two-line list + subtitle for scanning and the full safely wrapped value in persona detail. + The dialog field + lives in `ui/AgentDescriptionField.tsx` (`AgentIdentityFields`), not + inline in the over-1000-line dialogs. + +16. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** The compiled owner-only capability applies when Desktop starts or deploys a managed agent. Independently operated relay agents with NIP-OA ownership remain eligible in every build when their verified owner's signed @@ -250,7 +302,32 @@ with a TypeScript lookup table or an id comparison in a component. refresh only local persona/team/managed-agent caches; they must never invalidate the remote relay directory. -15. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. +17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. + +## Channel-only runtime controls + +Desktop observer controls identify a channel, not a thread session. The harness +rejects `cancel_turn` and `switch_model` with `ambiguous_target` when that channel +has multiple known session scopes, including retained idle scopes. Do not treat +that result as success or a deferred model switch. Stop feedback waits for the +harness result matching the control type, channel, and request ID; relay delivery +alone does not prove that a turn was signalled. A missing result is unconfirmed, +not success. The activity pane must use its resolved `sessionChannelId` for +both the outgoing control and result correlation, even without a loaded +`Channel` object. Stop is unavailable in an unscoped all-channel pane. + +Per-thread observer controls remain a separate protocol/UI change. Do not tell +users to type `!cancel` beside an inline mention: the owner command requires +kind 9, body exactly `!cancel` after trimming, and the agent's separate `p` tag. +The automatic-mention picker also inserts literal `@Name` into the body, so it +does not provide an exact-command workaround. The UI must state this limitation +rather than offer an ineffective command. An authorized owner can instead use +the CLI with the channel and target thread root: + +```sh +buzz messages send --channel --reply-to \ + --mention --content '!cancel' +``` ## The tests that enforce this @@ -289,6 +366,11 @@ with a TypeScript lookup table or an id comparison in a component. acceptance coverage for readiness, failure states, defaults, session-draft restoration, zero-write Skip, Next save failure/retry, navigation, and successful-empty vs failed optional-model discovery. +- `desktop/tests/e2e/agents.spec.ts` — community catalog descriptions remain + visible in the list and full detail before Add agent, including long + unbroken Unicode text without horizontal overflow. +- `lib/agentDescription.test.mjs` — authored-description resolution: trim, + blank/missing → null. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. diff --git a/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs b/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs new file mode 100644 index 00000000000..26bf6fe46a4 --- /dev/null +++ b/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyReusableAgentAccessPolicy } from "./channelAgents.ts"; + +const AGENT_PUBKEY = "a".repeat(64); +const ALLOWED_PUBKEY = "b".repeat(64); + +// `wrote` is load-bearing: the message-send path (useMentionSendFlow) uses it +// to decide whether an awaited relay round-trip separated its pre-side-effect +// mention-authorization pass from the publish, and therefore whether it must +// revalidate at the publish boundary (#5681). These tests pin the flag against +// the relay write itself, not against the identity of the returned record. + +function rawAgent(overrides = {}) { + return { + pubkey: AGENT_PUBKEY, + name: "fizz", + persona_id: null, + relay_url: "wss://relay.example", + acp_command: "buzz-acp", + agent_command: "goose", + agent_args: [], + mcp_command: "", + turn_timeout_seconds: 0, + idle_timeout_seconds: 0, + max_turn_duration_seconds: 0, + parallelism: 1, + system_prompt: null, + model: null, + status: "running", + pid: null, + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + log_path: null, + start_on_app_launch: false, + backend: { type: "local" }, + backend_agent_id: null, + respond_to: "owner-only", + respond_to_allowlist: [], + ...overrides, + }; +} + +function managedAgent(overrides = {}) { + return { + pubkey: AGENT_PUBKEY, + name: "fizz", + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function installTauriInvoke(handler) { + const prior = globalThis.window; + globalThis.window ??= {}; + window.__TAURI_INTERNALS__ = { invoke: handler }; + return () => { + globalThis.window = prior; + }; +} + +test("a matching access policy reports no write and returns the agent untouched", async (t) => { + const calls = []; + t.after( + installTauriInvoke((command, args) => { + calls.push([command, args]); + return Promise.resolve(null); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, {}); + + assert.equal(result.wrote, false); + assert.equal(result.agent, agent); + assert.deepEqual(calls, []); +}); + +test("a diverging access policy reports the write and returns the updated agent", async (t) => { + const calls = []; + t.after( + installTauriInvoke((command, args) => { + calls.push([command, args]); + return Promise.resolve({ + agent: rawAgent({ + respond_to: "allowlist", + respond_to_allowlist: [ALLOWED_PUBKEY], + }), + profile_sync_error: null, + }); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, { + respondTo: "allowlist", + respondToAllowlist: [ALLOWED_PUBKEY], + }); + + assert.equal(result.wrote, true); + assert.equal(result.agent.respondTo, "allowlist"); + assert.deepEqual(result.agent.respondToAllowlist, [ALLOWED_PUBKEY]); + assert.deepEqual(calls, [ + [ + "update_managed_agent", + { + input: { + pubkey: AGENT_PUBKEY, + respondTo: "allowlist", + respondToAllowlist: [ALLOWED_PUBKEY], + }, + }, + ], + ]); +}); + +test("the write is reported even when the update hands back an unchanged record", async (t) => { + // Callers must not re-derive the write by comparing the returned record + // against the one they passed in — a backend that normalizes the policy + // away, or a cache layer that mutates in place and hands the caller's own + // object back, still wrote to the relay. Under such a comparison the send + // path would silently skip the publish-boundary revalidation. + let invoked = 0; + t.after( + installTauriInvoke(() => { + invoked += 1; + return Promise.resolve({ + agent: rawAgent(), + profile_sync_error: null, + }); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, { + respondTo: "anyone", + }); + + assert.equal(invoked, 1); + assert.equal(result.wrote, true); + assert.equal(result.agent.respondTo, agent.respondTo); + assert.deepEqual(result.agent.respondToAllowlist, agent.respondToAllowlist); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 3387d135af1..24ace21b520 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -36,6 +36,16 @@ export type AttachManagedAgentToChannelInput = { agent: ManagedAgent; role?: Exclude; ensureRunning?: boolean; + /** + * When set, a needed start/deploy is handed to this callback instead of + * being awaited: the attach resolves as soon as the membership write lands + * and the callback owns the start, including surfacing its failure. The + * message-send path passes a queue collector here — the wake it records is + * flushed fire-and-forget only after the relay accepts the publish, with a + * replay floor stamped at queue time, so the spawned harness replays the + * published message and an aborted send leaves no orphan wake. + */ + detachedStart?: (agent: ManagedAgent) => void; }; export type AttachManagedAgentToChannelResult = { @@ -85,6 +95,9 @@ export type CreateChannelManagedAgentInput = { respondToAllowlist?: string[]; /** Skip reuse logic and always create a fresh agent instance. */ forceNewInstance?: boolean; + /** Detached start hook forwarded to the channel attach — see + * `AttachManagedAgentToChannelInput.detachedStart`. */ + detachedStart?: (agent: ManagedAgent) => void; }; export type CreateChannelManagedAgentResult = @@ -120,11 +133,25 @@ type ChannelAgentReuseContext = { >[]; }; +export type ApplyReusableAgentAccessPolicyResult = { + agent: ManagedAgent; + /** + * True when reconciling the policy required a relay write. Callers that + * sequence authorization around this call — the message-send path revalidates + * mention authorization at the publish boundary whenever an awaited relay + * round-trip separated it from its earlier pass — depend on this flag rather + * than on comparing the returned record's identity against the input, so the + * signal survives any future change to whether an update returns a fresh + * object. + */ + wrote: boolean; +}; + export async function applyReusableAgentAccessPolicy( agent: ManagedAgent, request: Pick, persona?: Pick, -) { +): Promise { const policy = resolveReusableAgentAccessPolicy(request, persona); const matches = agent.respondTo === policy.respondTo && @@ -132,14 +159,13 @@ export async function applyReusableAgentAccessPolicy( agent.respondToAllowlist.every( (pubkey, index) => pubkey === policy.respondToAllowlist[index], ); - if (matches) return agent; - - return ( - await updateManagedAgent({ - pubkey: agent.pubkey, - ...policy, - }) - ).agent; + if (matches) return { agent, wrote: false }; + + const { agent: updatedAgent } = await updateManagedAgent({ + pubkey: agent.pubkey, + ...policy, + }); + return { agent: updatedAgent, wrote: true }; } export async function attachManagedAgentToChannel( @@ -177,16 +203,16 @@ export async function attachManagedAgentToChannel( // pair — so this ensures the pair the caller is attaching to, never // another community's. const isRemote = input.agent.backend.type === "provider"; - if (isRemote && input.agent.status !== "deployed") { - agent = await startManagedAgent(input.agent.pubkey); - started = true; - } else if ( - !isRemote && - input.agent.status !== "running" && - input.agent.status !== "deployed" - ) { - agent = await startManagedAgent(input.agent.pubkey); - started = true; + const needsStart = isRemote + ? input.agent.status !== "deployed" + : input.agent.status !== "running" && input.agent.status !== "deployed"; + if (needsStart) { + if (input.detachedStart) { + input.detachedStart(input.agent); + } else { + agent = await startManagedAgent(input.agent.pubkey); + started = true; + } } } @@ -317,7 +343,7 @@ export async function provisionChannelManagedAgent( const definition = context.personas.find( (persona) => persona.id === input.personaId, ); - const updatedAgent = await applyReusableAgentAccessPolicy( + const { agent: updatedAgent } = await applyReusableAgentAccessPolicy( reusable, input, definition, @@ -346,7 +372,7 @@ export async function provisionChannelManagedAgent( context.channelMemberPubkeys, ); if (reusable) { - const updatedAgent = await applyReusableAgentAccessPolicy( + const { agent: updatedAgent } = await applyReusableAgentAccessPolicy( reusable, input, ); @@ -411,6 +437,7 @@ export async function createChannelManagedAgent( agent: provisioned.agent, role: input.role ?? "bot", ensureRunning: input.ensureRunning ?? true, + detachedStart: input.detachedStart, }); return { diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 3daf4fa78cc..ec1ccd262e8 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -594,6 +594,7 @@ export function useStartManagedAgentMutation() { pubkey: string; expectedRelayUrl?: string; expectedSignerPubkey?: string; + replayFloorUnix?: number; }, ) => typeof input === "string" @@ -601,6 +602,7 @@ export function useStartManagedAgentMutation() { : startManagedAgent(input.pubkey, { expectedRelayUrl: input.expectedRelayUrl, expectedSignerPubkey: input.expectedSignerPubkey, + replayFloorUnix: input.replayFloorUnix, }), onSuccess: (updated) => { queryClient.setQueryData( diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 92159ff2754..520b51b99ea 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -79,20 +79,93 @@ test("Goose exposes provider, model, and its real effort application key", () => scope: "global", }); - assert.equal( - field(model, "effort").optionSource, - "legacyProviderModelCatalog", - ); + assert.equal(field(model, "effort").optionSource, "harnessNative"); assert.deepEqual(field(model, "effort").currentPersistence, { kind: "envVar", - key: "BUZZ_AGENT_THINKING_EFFORT", + key: "GOOSE_THINKING_EFFORT", }); assert.deepEqual(field(model, "effort").targetApplication, { kind: "envVar", key: "GOOSE_THINKING_EFFORT", }); + // Goose reads/writes its native key at global scope — the launch projection's + // global tier is native-only, so the legacy BUZZ_AGENT_THINKING_EFFORT in the + // config is not surfaced as the effort value (it would be silently ignored). + assert.equal(field(model, "effort").value, null); }); +// Carl (review 5036131024): global/onboarding effort persistence must use the +// runtime's native key so a selection reaches the spawn. The launch projection's +// global tier reads native-only (legacy alias is record/persona-scope), so +// persisting the legacy key for Goose round-trips in the UI but is ignored at +// spawn. Both scopes derive the same persistence/application key. +for (const scope of ["global", "onboarding"]) { + test(`effort persists to the runtime native key at ${scope} scope`, () => { + const goose = deriveAgentConfigFieldModel({ + config: { ...config, env_vars: { GOOSE_THINKING_EFFORT: "high" } }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const gooseEffort = field(goose, "effort"); + assert.deepEqual(gooseEffort.currentPersistence, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.deepEqual(gooseEffort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(gooseEffort.value, "high"); + assert.deepEqual(structuredEnvKeys([gooseEffort]), [ + "GOOSE_THINKING_EFFORT", + ]); + + const buzz = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope, + }); + const buzzEffort = field(buzz, "effort"); + assert.deepEqual(buzzEffort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.equal(buzzEffort.value, "high"); + }); +} + +// Per-agent scopes (definition/instance) intentionally keep effort on the +// generic legacy BUZZ_AGENT_THINKING_EFFORT row until PR 2.7 migrates Goose — +// currentPersistence/value stay legacy while targetApplication is native +// (agents/AGENTS.md rule 2). The scope gate must not broaden to these scopes. +for (const scope of ["definition", "instance"]) { + test(`Goose effort stays on the legacy persistence key at ${scope} scope`, () => { + const model = deriveAgentConfigFieldModel({ + config: { + ...config, + env_vars: { + BUZZ_AGENT_THINKING_EFFORT: "high", + GOOSE_THINKING_EFFORT: "low", + }, + }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const effort = field(model, "effort"); + assert.deepEqual(effort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.deepEqual(effort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(effort.value, "high"); + }); +} + test("Claude models effort as a deferred native ACP option", () => { const model = deriveAgentConfigFieldModel({ config, @@ -561,3 +634,90 @@ test("NUMERIC_KIND_MIN_contextLimit_is_1", () => { test("NUMERIC_KIND_MIN_maxRounds_is_0", () => { assert.equal(NUMERIC_KIND_MIN.maxRounds, 0); }); + +// ── P2 regression: Goose optionSource + isHarnessNativeEffort guard ─────────── +// +// Source-level reproduction of the P2 blocker: save global Goose defaults with +// GOOSE_THINKING_EFFORT=off, then open AI defaults. Previously, optionSource +// was "legacyProviderModelCatalog" → AgentConfigFields passed the persisted key +// to useEffortAutoClear with buzz-agent provider/model vocab → "off" not in +// that list → hook deleted the valid native value on mount. Fix: emit +// "harnessNative" so AgentConfigFields can detect isHarnessNativeEffort and +// make the hook a no-op. + +test("Goose_optionSource_is_harnessNative_not_legacyProviderModelCatalog", () => { + // The sole optionSource change (P2 fix): Goose must NOT be + // "legacyProviderModelCatalog" because that routes effort into the + // buzz-agent provider/model catalog, deleting valid Goose values on mount. + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "global", + }); + const effortField = field(model, "effort"); + assert.equal( + effortField.optionSource, + "harnessNative", + 'Goose global optionSource must be "harnessNative" — "legacyProviderModelCatalog" routes to buzz-agent vocab and deletes valid `off` on mount', + ); +}); + +test("Goose_global_off_value_is_preserved_by_harnessNative_optionSource", () => { + // A saved GOOSE_THINKING_EFFORT=off must round-trip through the field model + // without deletion. The field value reflects the config value, and + // optionSource="harnessNative" signals to AgentConfigFields that the + // auto-clear hook should be a no-op (no buzz-agent vocab gate). + const savedConfig = { + ...config, + env_vars: { GOOSE_THINKING_EFFORT: "off" }, + }; + const model = deriveAgentConfigFieldModel({ + config: savedConfig, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "global", + }); + const effortField = field(model, "effort"); + assert.equal( + effortField.optionSource, + "harnessNative", + "Goose effort field must use harnessNative optionSource", + ); + assert.equal( + effortField.value, + "off", + "saved GOOSE_THINKING_EFFORT=off must survive round-trip through field model (not deleted by buzz-agent vocab check)", + ); +}); + +test("Goose_onboarding_optionSource_is_harnessNative", () => { + // Same contract at onboarding scope — the persistence key is the native key + // at both global and onboarding, so both must guard against buzz-agent vocab. + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "onboarding", + }); + assert.equal( + field(model, "effort").optionSource, + "harnessNative", + "Goose onboarding optionSource must also be harnessNative", + ); +}); + +test("buzz_agent_optionSource_unchanged_still_buzzAgentCatalog", () => { + // Ensure the fix did not accidentally change buzz-agent's optionSource. + // buzz-agent's effort MUST go through the provider/model catalog for the + // per-provider effort validation to work (e.g. "none" vs "off"). + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope: "global", + }); + assert.equal( + field(model, "effort").optionSource, + "buzzAgentCatalog", + "buzz-agent optionSource must remain buzzAgentCatalog", + ); +}); diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5a8b8cb1c37..de31e724cc1 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -204,19 +204,31 @@ export function deriveAgentConfigFieldModel({ }); if (runtime?.thinkingEnvVar) { + // targetApplication is always the runtime's native key — how the harness + // should receive effort. currentPersistence (where the value lives today) + // is scope-split until PR 2.7 migrates per-agent Goose/Claude: + // - global/onboarding: native key, matching the launch projection's global + // tier (native-only; the legacy alias is record/persona scope), so a + // selection actually reaches the spawn rather than persisting a key the + // projection ignores. For buzz-agent this IS BUZZ_AGENT_THINKING_EFFORT. + // - definition/instance: still the generic legacy BUZZ_AGENT_THINKING_EFFORT + // row, unchanged pending the per-agent migration. + const nativeKey = runtime.thinkingEnvVar; + const persistenceKey = + scope === "global" || scope === "onboarding" + ? nativeKey + : BUZZ_AGENT_THINKING_EFFORT; fields.push({ kind: "effort", optionSource: - runtime.id === "buzz-agent" - ? "buzzAgentCatalog" - : "legacyProviderModelCatalog", + runtime.id === "buzz-agent" ? "buzzAgentCatalog" : "harnessNative", currentPersistence: { kind: "envVar", - key: BUZZ_AGENT_THINKING_EFFORT, + key: persistenceKey, }, - targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar }, + targetApplication: { kind: "envVar", key: nativeKey }, render: "control", - value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), + value: valueFromEnv(config, persistenceKey), }); } else if (runtime?.id === "claude") { fields.push({ diff --git a/desktop/src/features/agents/lib/agentDescription.test.mjs b/desktop/src/features/agents/lib/agentDescription.test.mjs new file mode 100644 index 00000000000..52e9d65a53a --- /dev/null +++ b/desktop/src/features/agents/lib/agentDescription.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentDescriptionCharacterCount, + clampAgentDescription, + effectiveAgentDescription, +} from "./agentDescription.ts"; + +test("description character count matches Rust Unicode scalar counting", () => { + assert.equal(agentDescriptionCharacterCount("a🐝é"), 3); + assert.equal(agentDescriptionCharacterCount("🐝".repeat(280)), 280); +}); + +test("description clamp preserves a useful prefix for over-cap pastes", () => { + assert.equal(clampAgentDescription("a".repeat(300)), "a".repeat(280)); + assert.equal( + clampAgentDescription(`${"a".repeat(279)}🐝extra`), + `${"a".repeat(279)}🐝`, + ); +}); + +test("an authored description wins", () => { + assert.equal( + effectiveAgentDescription({ description: "Reviews desktop PRs." }), + "Reviews desktop PRs.", + ); +}); + +test("an authored description is trimmed", () => { + assert.equal( + effectiveAgentDescription({ description: " Reviews desktop PRs. " }), + "Reviews desktop PRs.", + ); +}); + +test("blank, whitespace-only, and missing descriptions yield null", () => { + assert.equal(effectiveAgentDescription({ description: "" }), null); + assert.equal(effectiveAgentDescription({ description: " " }), null); + assert.equal(effectiveAgentDescription({ description: null }), null); + assert.equal(effectiveAgentDescription({}), null); +}); diff --git a/desktop/src/features/agents/lib/agentDescription.ts b/desktop/src/features/agents/lib/agentDescription.ts new file mode 100644 index 00000000000..7a1b8ae2c0c --- /dev/null +++ b/desktop/src/features/agents/lib/agentDescription.ts @@ -0,0 +1,29 @@ +import type { AgentPersona } from "@/shared/api/types"; + +/** Hard cap on a public agent description, mirroring the Rust validator. */ +export const MAX_AGENT_DESCRIPTION_CHARS = 280; + +/** Count Unicode scalar values, matching Rust's `str::chars().count()`. */ +export function agentDescriptionCharacterCount(value: string): number { + return Array.from(value).length; +} + +/** Clamp pasted/inserted text to the Rust description cap by Unicode scalar. */ +export function clampAgentDescription(value: string): string { + return Array.from(value).slice(0, MAX_AGENT_DESCRIPTION_CHARS).join(""); +} + +/** + * The description to display for a persona: the authored `description`, + * trimmed, when non-empty; otherwise `null`. + * + * Rust twin: `effective_agent_description` in + * `managed_agents/agent_description.rs`, which resolves the same value on + * the kind:0 `about` publish path — keep both in sync. + */ +export function effectiveAgentDescription( + persona: Partial>, +): string | null { + const authored = persona.description?.trim() ?? ""; + return authored.length > 0 ? authored : null; +} diff --git a/desktop/src/features/agents/lib/cancelTurnOutcome.test.mjs b/desktop/src/features/agents/lib/cancelTurnOutcome.test.mjs new file mode 100644 index 00000000000..774d3e5e7a5 --- /dev/null +++ b/desktop/src/features/agents/lib/cancelTurnOutcome.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { awaitCancelTurnOutcome } from "./cancelTurnOutcome.ts"; + +function harness(sendCancel = async () => {}) { + let listener; + let timeout; + let unsubscribed = false; + let timeoutCancelled = false; + const outcome = awaitCancelTurnOutcome({ + requestId: "request-a", + channelId: "channel-a", + subscribe: (fn) => { + listener = fn; + return () => { + unsubscribed = true; + }; + }, + sendCancel, + scheduleTimeout: (fn) => { + timeout = fn; + return () => { + timeoutCancelled = true; + }; + }, + }); + return { + outcome, + push: (status, overrides = {}) => + listener({ + type: "cancel_turn", + requestId: "request-a", + channelId: "channel-a", + status, + ...overrides, + }), + timeout: () => timeout(), + assertCleaned: () => { + assert.equal(unsubscribed, true); + assert.equal(timeoutCancelled, true); + }, + }; +} + +for (const status of ["sent", "no_active_turn", "ambiguous_target"]) { + test(`stop returns the correlated harness result: ${status}`, async () => { + const h = harness(); + h.push(status); + assert.equal(await h.outcome, status); + h.assertCleaned(); + }); +} + +test("relay delivery, old harnesses, replay, other channels and model acks cannot confirm a stop", async () => { + const h = harness(); + h.push("sent", { requestId: undefined }); + h.push("sent", { requestId: "old-request" }); + h.push("sent", { channelId: "channel-b" }); + h.push("sent", { type: "switch_model" }); + h.push("future_status"); + h.timeout(); + assert.equal(await h.outcome, "unconfirmed"); + h.assertCleaned(); +}); + +test("stop unsubscribes and clears timeout after a transport error", async () => { + const h = harness(async () => { + throw new Error("transport"); + }); + await assert.rejects(h.outcome, /transport/); + h.assertCleaned(); +}); + +test("a hung transport cannot block the unconfirmed timeout", async () => { + const h = harness(() => new Promise(() => {})); + h.timeout(); + assert.equal(await h.outcome, "unconfirmed"); + h.assertCleaned(); +}); + +test("a harness result can settle before the send promise resolves", async () => { + const h = harness(() => new Promise(() => {})); + h.push("sent"); + assert.equal(await h.outcome, "sent"); + h.assertCleaned(); +}); + +test("a late transport rejection does not replace the settled result", async () => { + let rejectSend; + const h = harness( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ); + h.timeout(); + assert.equal(await h.outcome, "unconfirmed"); + rejectSend(new Error("late transport error")); + await new Promise((resolve) => setImmediate(resolve)); + h.assertCleaned(); +}); diff --git a/desktop/src/features/agents/lib/cancelTurnOutcome.ts b/desktop/src/features/agents/lib/cancelTurnOutcome.ts new file mode 100644 index 00000000000..ee506058147 --- /dev/null +++ b/desktop/src/features/agents/lib/cancelTurnOutcome.ts @@ -0,0 +1,76 @@ +import type { ControlResultFrame } from "@/shared/api/types"; + +/** Stop feedback must describe the harness result, not relay delivery alone. */ +export async function awaitCancelTurnOutcome({ + requestId, + channelId, + subscribe, + sendCancel, + scheduleTimeout, +}: { + requestId: string; + channelId: string; + subscribe: (listener: (frame: ControlResultFrame) => void) => () => void; + sendCancel: () => Promise; + scheduleTimeout: (onTimeout: () => void) => () => void; +}): Promise<"sent" | "no_active_turn" | "ambiguous_target" | "unconfirmed"> { + type Outcome = "sent" | "no_active_turn" | "ambiguous_target" | "unconfirmed"; + + let settled = false; + let unsubscribe = () => {}; + let cancelTimeout = () => {}; + let resolveResult: (outcome: Outcome) => void = () => {}; + let rejectResult: (error: unknown) => void = () => {}; + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + const cleanup = () => { + unsubscribe(); + cancelTimeout(); + }; + const settle = (outcome: Outcome) => { + if (settled) return; + settled = true; + cleanup(); + resolveResult(outcome); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + rejectResult(error); + }; + + unsubscribe = subscribe((frame) => { + if ( + frame.type !== "cancel_turn" || + frame.requestId !== requestId || + frame.channelId !== channelId + ) { + return; + } + if ( + frame.status === "sent" || + frame.status === "no_active_turn" || + frame.status === "ambiguous_target" + ) { + settle(frame.status); + } + }); + // Start the timeout before sending. A hung relay transport must not keep the + // caller pending forever; timeout truthfully reports that the harness result + // was not confirmed. The send promise is still observed below so a later + // rejection cannot become an unhandled rejection. + cancelTimeout = scheduleTimeout(() => settle("unconfirmed")); + try { + // Race transport failure against the harness result. A correlated result + // may arrive before publish resolves, and a transport that hangs must not + // block the timeout from settling the outer operation. + void Promise.resolve(sendCancel()).catch(fail); + } catch (error) { + fail(error); + } + + return result; +} diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs index 4a79d32837b..ed08be3c933 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -75,6 +75,23 @@ const drainMicrotasks = async () => { } }; +test("ambiguous sibling sessions reject the pick even after another channel switched", async () => { + const h = harness([CH_A, CH_B]); + h.push(frame("switched")); + h.push(frame("ambiguous_target", { channelId: CH_B })); + assert.equal(await h.outcome, "ambiguous"); + assert.equal(h.cancelTimeoutCalls, 1); + assert.equal(h.unsubscribeCalls, 1); +}); + +test("stale or foreign ambiguity does not reject a live model pick", async () => { + const h = harness([CH_A]); + h.push(frame("ambiguous_target", { requestId: "old-pick" })); + h.push(frame("ambiguous_target", { channelId: CH_B })); + h.push(frame("switched")); + assert.equal(await h.outcome, "ok"); +}); + test("awaitLiveSwitchOutcome fast sent on one channel does not mask a later unsupported on another", async () => { const h = harness([CH_A, CH_B]); // Channel A acks fast as `sent`; a first-ack-resolves impl would settle "ok" diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts index 83792dcdab2..fa5bbfd196b 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.ts +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts @@ -41,6 +41,9 @@ import type { ControlResultFrame } from "@/shared/api/types"; * Both fail-fast to `"not_delivered"`, distinct from `"pending"` (which DID * ride the requeued session): here the switch never landed at all. * + * `ambiguous_target` rejects a channel-only pick when the harness knows more + * than one session scope in that channel. No sibling session was changed. + * * Any other status — a `sent` provisional ack, or an unknown future status — is * inert: it is never counted as success. A new producer status that should * settle the pick must add its own explicit branch. @@ -79,16 +82,24 @@ export async function awaitLiveSwitchOutcome({ sendSwitches: () => Promise; /** Schedule the no-reply fallback; returns a cancel function. */ scheduleTimeout: (onTimeout: () => void) => () => void; -}): Promise<"ok" | "unsupported" | "failed" | "not_delivered" | "pending"> { +}): Promise< + "ok" | "unsupported" | "failed" | "not_delivered" | "pending" | "ambiguous" +> { const expected = new Set(channelIds); const settled = new Promise< - "ok" | "unsupported" | "failed" | "not_delivered" | "pending" + "ok" | "unsupported" | "failed" | "not_delivered" | "pending" | "ambiguous" >((resolve) => { let unsubscribe = () => {}; let cancelTimeout = () => {}; const succeeded = new Set(); const finish = ( - outcome: "ok" | "unsupported" | "failed" | "not_delivered" | "pending", + outcome: + | "ok" + | "unsupported" + | "failed" + | "not_delivered" + | "pending" + | "ambiguous", ) => { cancelTimeout(); unsubscribe(); @@ -112,6 +123,10 @@ export async function awaitLiveSwitchOutcome({ if (!frame.channelId || !expected.has(frame.channelId)) { return; } + if (frame.status === "ambiguous_target") { + finish("ambiguous"); + return; + } if (frame.status === "unsupported_model") { // Model unavailable — reject the whole pick immediately. finish("unsupported"); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cce..aaf10075e0d 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -1,10 +1,6 @@ import { sendChannelMessage } from "@/shared/api/tauri"; -import type { - Channel, - ManagedAgent, - PresenceLookup, - RelayAgent, -} from "@/shared/api/types"; +import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { AgentAvailabilityReader } from "./useAgentAvailability"; import { normalizePubkey } from "@/shared/lib/pubkey"; type DeleteManagedAgentInput = { @@ -23,7 +19,7 @@ type ManagedAgentChannelContext = { }; type ManagedAgentActionContext = ManagedAgentChannelContext & { - presenceLookup?: PresenceLookup | null; + getAvailability: AgentAvailabilityReader; }; export type ManagedAgentActionResult = { @@ -31,6 +27,7 @@ export type ManagedAgentActionResult = { noticeMessage?: string; }; +/** Lifecycle action routing only; deployed is a retained receipt, not presence. */ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } @@ -133,7 +130,8 @@ export async function stopManagedAgentWithRules({ agent.pubkey, ]); return { - noticeMessage: "Shutdown command sent. Agent will stop shortly.", + noticeMessage: + "Shutdown requested. This does not confirm the agent has stopped.", }; } @@ -146,7 +144,7 @@ export async function deleteManagedAgentWithRules({ channels, deleteManagedAgent, preferredChannelId, - presenceLookup, + getAvailability, relayAgents, skipRemoteDeleteConfirm = false, }: { @@ -155,7 +153,7 @@ export async function deleteManagedAgentWithRules({ skipRemoteDeleteConfirm?: boolean; } & ManagedAgentActionContext): Promise { if (agent.backend.type === "provider" && agent.backendAgentId) { - const presence = presenceLookup?.[normalizePubkey(agent.pubkey)]; + const availability = getAvailability(agent.pubkey); const channelId = resolveManagedAgentChannelId(agent, { channels, preferredChannelId, @@ -163,14 +161,19 @@ export async function deleteManagedAgentWithRules({ }); if (channelId) { - if (presence === "online" || presence === "away") { + // Only established Offline preserves the intentional no-request path. + // Unknown is not evidence that shutdown can safely be skipped. + if (availability !== "offline") { await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ agent.pubkey, ]); if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( - "Shutdown command sent, but the agent may still be running. " + + (availability === undefined + ? "This agent’s availability is unknown. " + : "") + + "Shutdown requested, but the agent may still be running. " + "Deleting now removes the local record — the remote deployment " + "will be orphaned if shutdown hasn't completed. Continue?", ); @@ -193,7 +196,7 @@ export async function deleteManagedAgentWithRules({ if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( "This agent is deployed but not in any channel. " + - "Deleting will orphan the remote deployment (it will keep running). Continue?", + "Deleting removes the local management record; the remote deployment may still be running. Continue?", ); if (!confirmed) { return { cancelled: true }; diff --git a/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs b/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs new file mode 100644 index 00000000000..894af01fd4b --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs @@ -0,0 +1,469 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +const PK = "a".repeat(64); +const SIBLING = "b".repeat(64); +const agent = { + pubkey: PK, + name: "Remote", + personaId: "persona", + status: "deployed", + backend: { type: "provider", id: "fixture", config: {} }, + backendAgentId: "receipt", +}; +const channel = { id: "channel", name: "agents", memberPubkeys: [PK] }; +const directory = [ + { pubkey: PK, channels: ["agents"], channelIds: ["channel"] }, +]; +let act, + render, + cleanup, + waitFor, + createElement, + QueryClient, + QueryClientProvider; +let useAgentAvailabilityLookup, + useManagedAgentActions, + useProfileAgentDeletion, + CommunitiesProvider; +let deleteManagedAgentWithRules, deleteManagedAgent, relayClient, originals; +let connection, listeners, handlers, commands, confirms, clients; + +before(async () => { + Object.assign(globalThis, { + window: dom.window, + localStorage: dom.window.localStorage, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + commands.push([command, args]); + if (handlers.has(command)) return handlers.get(command)(args); + throw new Error(`Unexpected IPC: ${command}`); + }, + transformCallback: () => 1, + }; + ({ act, render, cleanup, waitFor } = await import("@testing-library/react")); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "../../communities/useCommunities.tsx" + )); + ({ useAgentAvailabilityLookup } = await import("./useAgentAvailability.ts")); + ({ useManagedAgentActions } = await import( + "../ui/useManagedAgentActions.ts" + )); + ({ useProfileAgentDeletion } = await import( + "../../profile/ui/UserProfilePanelDeletion.ts" + )); + ({ deleteManagedAgentWithRules } = await import( + "./managedAgentControlActions.ts" + )); + ({ deleteManagedAgent } = await import("../../../shared/api/tauri.ts")); + ({ relayClient } = await import("../../../shared/api/relayClient.ts")); + originals = { + getConnectionState: relayClient.getConnectionState, + subscribeToConnectionState: relayClient.subscribeToConnectionState, + }; + relayClient.getConnectionState = () => connection; + relayClient.subscribeToConnectionState = (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; +}); + +afterEach(() => { + cleanup(); + for (const client of clients ?? []) { + client.cancelQueries(); + client.clear(); + } +}); +after(() => { + Object.assign(relayClient, originals); + dom.window.close(); +}); + +function setup() { + clients = []; + commands = []; + confirms = []; + connection = "connected"; + listeners = new Set(); + handlers = new Map([ + ["get_presence", () => ({ [PK]: "online" })], + ["delete_managed_agent", () => null], + ["remove_channel_member", () => null], + ["send_channel_message", () => ({ event_id: "event", created_at: 0 })], + ["list_managed_agents", () => []], + ["get_relay_agents", () => []], + ["list_available_acp_runtimes", () => []], + ["get_channels", () => []], + ["plugin:event|listen", () => 1], + ["plugin:event|unlisten", () => null], + ]); + dom.window.confirm = (copy) => { + confirms.push(copy); + return true; + }; +} + +function mount( + owner, + { agents = [agent], keys = [PK], seedChannels = true } = {}, +) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + mutations: { retry: false, gcTime: 0 }, + }, + }); + clients.push(client); + client.setQueryData(["managed-agents"], agents); + client.setQueryData(["relay-agents"], directory); + if (seedChannels) client.setQueryData(["channels"], [channel]); + client.setQueryData(["globalAgentConfig"], { env_vars: {} }); + let current; + function AgentsSurface() { + current = useManagedAgentActions(); + return null; + } + function ProfileSurface() { + const availability = useAgentAvailabilityLookup(keys); + const deletion = useProfileAgentDeletion({ + channels: [channel], + managedAgents: agents, + managedAgent: agents[0], + relayAgents: agents.map((row) => ({ + ...directory[0], + pubkey: row.pubkey, + })), + getAvailability: availability.getAvailability, + deleteManagedAgent: ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete), + }); + current = { ...availability, ...deletion }; + return null; + } + const Surface = owner === "agents" ? AgentsSurface : ProfileSurface; + render( + createElement( + QueryClientProvider, + { client }, + createElement(CommunitiesProvider, null, createElement(Surface)), + ), + ); + return { client, current: () => current }; +} + +function effects() { + return commands.filter(([name]) => + [ + "send_channel_message", + "delete_managed_agent", + "remove_channel_member", + ].includes(name), + ); +} + +for (const owner of ["agents", "profile"]) { + for (const scenario of [ + "online", + "away", + "offline", + "missing", + "pending", + "failed-online", + "failed-offline", + "disconnected-online", + "disconnected-offline", + ]) { + test(`${owner} deletion uses resolved ${scenario} at the production hook/IPC boundary`, async () => { + setup(); + const warm = scenario.endsWith("-offline") ? "offline" : "online"; + handlers.set("get_presence", () => { + if (scenario === "pending") return new Promise(() => {}); + if (scenario === "missing") return {}; + return { [PK]: scenario.includes("-") ? warm : scenario }; + }); + const surface = mount(owner); + const key = ["presence", PK]; + if (scenario !== "pending") { + await waitFor(() => + assert.equal(surface.client.getQueryState(key)?.status, "success"), + ); + } + if (scenario.startsWith("failed")) { + handlers.set("get_presence", () => + Promise.reject("relay unreachable: request timed out"), + ); + await act(() => + surface.client.invalidateQueries({ queryKey: key, exact: true }), + ); + assert.equal(surface.client.getQueryState(key).status, "error"); + assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm }); + } + if (scenario.startsWith("disconnected")) { + await act(async () => { + connection = "disconnected"; + for (const listener of listeners) listener(connection); + }); + assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm }); + } + const unknown = scenario.includes("-") || scenario === "pending"; + await waitFor(() => + assert.equal( + surface.current().getAvailability(PK), + unknown ? undefined : scenario === "missing" ? "offline" : scenario, + ), + ); + commands.length = 0; + await act(async () => { + if (owner === "agents") await surface.current().handleDelete(PK); + else await surface.current().deleteManagedAgentRecord(agent); + }); + const shouldShutdown = scenario !== "offline" && scenario !== "missing"; + assert.deepEqual( + effects().map(([name]) => name), + [ + ...(shouldShutdown ? ["send_channel_message"] : []), + "delete_managed_agent", + "remove_channel_member", + ], + ); + if (shouldShutdown) { + assert.equal(effects()[0][1].content, "!shutdown"); + assert.deepEqual(effects()[0][1].mentionPubkeys, [PK]); + } + assert.deepEqual( + effects().find(([name]) => name === "delete_managed_agent")[1], + { + pubkey: PK, + forceRemoteDelete: true, + }, + ); + if (owner === "agents") { + assert.equal(confirms.length, 1); + if (unknown) { + assert.match(confirms[0], /availability is unknown/); + assert.doesNotMatch(confirms[0], /offline/i); + } else if (!shouldShutdown) assert.match(confirms[0], /is offline/); + } else + assert.deepEqual(confirms, [], "profile already obtained confirmation"); + }); + } +} + +test("reader retained across an await sees errors/disconnect, not cached success; unqueried siblings stay unknown", async () => { + setup(); + const surface = mount("profile"); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "online"), + ); + const retainedReader = surface.current().getAvailability; + assert.equal(retainedReader(SIBLING), undefined); + handlers.set("get_presence", () => Promise.reject("failed")); + await act(() => + surface.client.invalidateQueries({ queryKey: ["presence", PK] }), + ); + assert.equal(retainedReader(PK), undefined); + await act(async () => + surface.client.setQueryData(["presence", PK], { [PK]: "online" }), + ); + connection = "reconnecting"; // even before the next React connection render + assert.equal(retainedReader(PK), undefined); +}); + +for (const owner of ["agents", "profile"]) { + test(`${owner} unknown shutdown failure preserves record and channel membership`, async () => { + setup(); + handlers.set("get_presence", () => Promise.reject("failed")); + handlers.set("send_channel_message", () => + Promise.reject(new Error("shutdown refused")), + ); + const surface = mount(owner); + await waitFor(() => + assert.equal( + surface.client.getQueryState(["presence", PK])?.status, + "error", + ), + ); + await act(async () => { + if (owner === "agents") await surface.current().handleDelete(PK); + else + await assert.rejects( + surface.current().deleteManagedAgentRecord(agent), + /shutdown refused/, + ); + }); + assert.deepEqual( + effects().map(([name]) => name), + ["send_channel_message"], + ); + assert.deepEqual(confirms, []); + if (owner === "agents") + assert.equal(surface.current().actionErrorMessage, "shutdown refused"); + }); +} + +test("unknown waits for shutdown before confirmation/delete; cancellation retains record", async () => { + setup(); + let release; + handlers.set( + "send_channel_message", + () => + new Promise((resolve) => { + release = resolve; + }), + ); + dom.window.confirm = (copy) => { + confirms.push(copy); + return false; + }; + const operation = deleteManagedAgentWithRules({ + agent, + channels: [channel], + relayAgents: directory, + getAvailability: () => undefined, + deleteManagedAgent: ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete), + }); + await waitFor(() => assert.equal(typeof release, "function")); + assert.deepEqual(confirms, []); + assert.equal(effects().length, 1); + release({ event_id: "event" }); + assert.deepEqual(await operation, { cancelled: true }); + assert.match(confirms[0], /availability is unknown/); + assert.equal(effects().length, 1); +}); + +test("no channel warns without claiming process state; local deletion ignores presence", async () => { + setup(); + const remove = ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete); + await deleteManagedAgentWithRules({ + agent, + channels: [], + relayAgents: [], + getAvailability: () => undefined, + deleteManagedAgent: remove, + }); + assert.match(confirms[0], /may still be running/); + assert.doesNotMatch(confirms[0], /will keep running|offline/i); + assert.deepEqual( + effects().map(([name]) => name), + ["delete_managed_agent"], + ); + commands.length = 0; + confirms.length = 0; + await deleteManagedAgentWithRules({ + agent: { ...agent, backend: { type: "local" } }, + channels: [], + relayAgents: [], + getAvailability: () => { + throw new Error("must not consult presence"); + }, + deleteManagedAgent: remove, + }); + assert.deepEqual(effects(), [ + ["delete_managed_agent", { pubkey: PK, forceRemoteDelete: null }], + ]); + assert.deepEqual(confirms, []); +}); + +test("Agents deletion rechecks availability after channel discovery, not the click-time snapshot", async () => { + setup(); + let releaseChannels; + handlers.set( + "get_channels", + () => + new Promise((resolve) => { + releaseChannels = resolve; + }), + ); + handlers.set("get_presence", () => ({ [PK]: "offline" })); + const surface = mount("agents", { seedChannels: false }); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "offline"), + ); + let operation; + await act(async () => { + operation = surface.current().handleDelete(PK); + }); + await waitFor(() => assert.equal(typeof releaseChannels, "function")); + handlers.set("get_presence", () => Promise.reject("failed")); + await act(() => + surface.client.invalidateQueries({ queryKey: ["presence", PK] }), + ); + assert.deepEqual(effects(), []); + await act(async () => { + releaseChannels({ hash: "empty", channels: [], last_messages: {} }); + await operation; + }); + assert.deepEqual( + effects().map(([name]) => name), + ["send_channel_message", "delete_managed_agent", "remove_channel_member"], + ); + assert.match(confirms[0], /availability is unknown/); +}); + +test("profile persona deletion cannot infer Offline for an unqueried sibling", async () => { + setup(); + handlers.set("get_presence", () => ({})); + const surface = mount("profile", { + agents: [agent, { ...agent, pubkey: SIBLING }], + keys: [PK], + }); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "offline"), + ); + assert.equal(surface.current().getAvailability(SIBLING), undefined); + await act(() => + surface.current().deleteManagedAgentsForPersona({ id: "persona" }), + ); + const requests = effects().filter( + ([name]) => name === "send_channel_message", + ); + assert.equal(requests.length, 1); + assert.deepEqual(requests[0][1].mentionPubkeys, [SIBLING]); + assert.match(confirms[0], /is offline/); + assert.match(confirms[1], /availability is unknown/); +}); + +test("successful cached snapshot remains authoritative during refetch; only settled error revokes it", async () => { + setup(); + const surface = mount("profile"); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "online"), + ); + let rejectRead; + handlers.set( + "get_presence", + () => + new Promise((_, reject) => { + rejectRead = reject; + }), + ); + let refresh; + await act(async () => { + refresh = surface.client.invalidateQueries({ queryKey: ["presence", PK] }); + }); + assert.equal(surface.current().getAvailability(PK), "online"); + await act(async () => { + rejectRead("failed"); + await refresh; + }); + assert.equal(surface.current().getAvailability(PK), undefined); +}); diff --git a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs index f57c7f8154f..a45ddb5e64c 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs +++ b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isOtherSetupAgent } from "./otherSetupAgent.ts"; +import { + isOtherSetupAgent, + isOwnedAgentNotManagedOnDevice, +} from "./otherSetupAgent.ts"; const OWNER = "a".repeat(64); const AGENT = "b".repeat(64); @@ -20,7 +23,7 @@ test("fails closed while the local managed directory is unresolved", () => { ); }); -test("labels a viewer-owned non-local identity as another setup", () => { +test("labels a viewer-owned identity as not managed on this device", () => { assert.equal( isOtherSetupAgent({ agentDirectoriesReady: true, @@ -33,3 +36,38 @@ test("labels a viewer-owned non-local identity as another setup", () => { true, ); }); + +test("a locally managed provider is not labeled as another device", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: true, + currentPubkey: OWNER, + managedAgents: [{ pubkey: AGENT, backend: { type: "provider" } }], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + false, + ); +}); + +for (const [name, overrides, expected] of [ + ["owned absent key", {}, true], + ["loading local inventory", { localInventoryReady: false }, false], + ["exact local provider record", { isLocallyManaged: true }, false], + ["different owner", { ownerPubkey: "b".repeat(64) }, false], + ["unknown ownership", { ownerPubkey: null }, false], +]) { + test(`shared provenance: ${name}`, () => { + assert.equal( + isOwnedAgentNotManagedOnDevice({ + currentPubkey: "a".repeat(64), + ownerPubkey: "A".repeat(64), + localInventoryReady: true, + isLocallyManaged: false, + ...overrides, + }), + expected, + ); + }); +} diff --git a/desktop/src/features/agents/lib/otherSetupAgent.ts b/desktop/src/features/agents/lib/otherSetupAgent.ts index 63438a983fd..f94215e1f3a 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.ts +++ b/desktop/src/features/agents/lib/otherSetupAgent.ts @@ -1,6 +1,7 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +/** Owned identity absent from the loaded local inventory; not evidence of hosting location. */ export function isOtherSetupAgent({ agentDirectoriesReady, currentPubkey, @@ -32,8 +33,31 @@ export function isOtherSetupAgent({ )?.ownerPubkey; const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey; + return isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady: agentDirectoriesReady, + isLocallyManaged: false, + }); +} + +/** Presentation provenance only; neither hosting location nor availability. */ +export function isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady, + isLocallyManaged, +}: { + currentPubkey?: string; + ownerPubkey?: string | null; + localInventoryReady: boolean; + isLocallyManaged: boolean; +}): boolean { return Boolean( - ownerPubkey && + localInventoryReady && + !isLocallyManaged && + currentPubkey && + ownerPubkey && normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), ); } diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 63a357e4487..928920f9a32 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -10,6 +10,8 @@ export type CatalogPersonaShareLevel = "not-shared" | "none"; type CatalogAgentProjection = { displayName: string; avatarUrl: string | null; + /** Optional public description (validated server-side; max 280 chars). */ + description: string | null; systemPrompt: string; runtime: string | null; model: string | null; @@ -69,6 +71,7 @@ function publicationToPersona( `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, displayName: publication.agent.displayName, avatarUrl: publication.agent.avatarUrl, + description: publication.agent.description ?? null, systemPrompt: publication.agent.systemPrompt, runtime: publication.agent.runtime, model: publication.agent.model, diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index 710b5fc4be8..9e542be5a90 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - pickDirectProfileAgent, - pickProfileAgent, -} from "./pickProfileAgent.ts"; +import { pickProfileAgent } from "./pickProfileAgent.ts"; const NONE_ARCHIVED = () => false; @@ -68,60 +65,3 @@ test("a fail-open predicate keeps every instance eligible while loading", () => // Fail-open (all false) during the archive-snapshot window: normal ranking. assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); }); - -test("a direct-opened active instance is never redirected to a sibling", () => { - // "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an - // access edit on Tyler would target the sibling. - const sibling = { - name: "Alpha Sibling", - pubkey: "a".repeat(64), - status: "running", - }; - const clicked = { - name: "Tyler Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED), - clicked, - ); -}); - -test("a direct-opened inactive instance redirects to the active sibling", () => { - const historical = { - name: "Earlier Parity Agent", - pubkey: "a".repeat(64), - status: "stopped", - }; - const current = { - name: "Current Parity Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED), - current, - ); -}); - -test("a direct-opened inactive instance with no active sibling stays put", () => { - const clicked = { - name: "Only Instance", - pubkey: "a".repeat(64), - status: "stopped", - }; - const otherStopped = { - name: "Another Stopped", - pubkey: "b".repeat(64), - status: "stopped", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED), - clicked, - ); - assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked); -}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index dc2437c86ea..19de21f7903 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -5,8 +5,8 @@ import type { ManagedAgent } from "@/shared/api/types"; * Pick the instance that represents a persona throughout the UI. * * A persona can have several historical agent instances. Keeping this rule in - * one place prevents an avatar click on an older message from opening a - * different detail surface than the card in the Agents library. + * one place keeps persona navigation consistent. Explicit pubkey navigation + * never uses this selector: older messages still name their exact author. * * Relay-archived instances are never eligible, so an archived record early in * file order can't hijack the persona target. Returns `undefined` when every @@ -28,25 +28,3 @@ export function pickProfileAgent( return left.name.localeCompare(right.name); })[0]; } - -/** - * Resolve which instance a profile panel opened for `directAgent` should - * show, given every instance of the same persona. - * - * Access edits must target the exact instance the user clicked — resolving a - * running sidebar member to an alphabetically-earlier sibling would let a - * "tighten access" save widen the wrong agent. But when the clicked instance - * is inactive and the persona has an active instance elsewhere (an avatar on - * an old message from a retired instance), redirect to the active one so the - * panel matches the Agents library. The `isArchived` predicate keeps that - * redirect from ever landing on an archived sibling. - */ -export function pickDirectProfileAgent( - directAgent: ManagedAgent, - personaInstances: readonly ManagedAgent[], - isArchived: (pubkey: string) => boolean, -) { - if (isManagedAgentActive(directAgent)) return directAgent; - const canonical = pickProfileAgent(personaInstances, isArchived); - return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; -} diff --git a/desktop/src/features/agents/lib/useAgentAvailability.test.mjs b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs new file mode 100644 index 00000000000..f9b2df050a4 --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { resolveAgentAvailability } from "./useAgentAvailability.ts"; +import { + getManagedAgentPrimaryActionLabel, + isManagedAgentActive, +} from "./managedAgentControlActions.ts"; +import { AgentRuntimeAvatarControl } from "../ui/AgentRuntimeAvatarControl.tsx"; + +const deployed = { + status: "deployed", + backend: { type: "provider", id: "fixture" }, + backendAgentId: "retained-receipt", +}; + +for (const presence of ["online", "away", "offline", undefined]) { + test(`retained deployment receipt does not supply availability (${presence})`, () => { + const availability = resolveAgentAvailability(presence, true, true); + assert.equal(availability, presence ?? "offline"); + // Controls retain their existing routing. Offline is not permission to + // spawn a second body, nor proof that a shutdown message succeeded. + assert.equal(isManagedAgentActive(deployed), true); + assert.equal(getManagedAgentPrimaryActionLabel(deployed), "Shutdown"); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.doesNotMatch(html, /is running/); + assert.match( + html, + new RegExp( + `Agent: ${availability[0].toUpperCase()}${availability.slice(1)}`, + ), + ); + assert.equal(html.includes("bg-emerald-500"), availability === "online"); + assert.doesNotMatch(html, /data-testid="start"/); + }); +} + +for (const [loaded, connected] of [ + [false, true], + [true, false], + [false, false], +]) { + test(`unavailable presence is unknown, not cached online (${loaded}, ${connected})`, () => { + const availability = resolveAgentAvailability("online", loaded, connected); + assert.equal(availability, undefined); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.match(html, /Availability unknown/); + assert.doesNotMatch(html, /bg-emerald-500|is running/); + }); +} + +for (const lifecycle of ["running", "stopped"]) { + test(`local ${lifecycle} controls remain independent of online presence`, () => { + const agent = { status: lifecycle, backend: { type: "local" } }; + const isActive = isManagedAgentActive(agent); + assert.equal( + getManagedAgentPrimaryActionLabel(agent), + isActive ? "Stop" : "Start agent", + ); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive, + availability: "online", + isStarting: false, + label: "Local Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.equal(html.includes('data-testid="start"'), false); + assert.equal(html.includes('data-testid="active"'), true); + }); +} + +for (const availability of ["online", "away"]) { + test(`stale restart and runtime error cannot hide stopped ${availability} presence`, () => { + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + startTestId: "start", + errorTestId: "error", + isActive: false, + isStarting: false, + requiresRestart: true, + errorLabel: "Previous startup failed", + availability, + label: "Agent", + onStart() {}, + }), + ); + assert.match(html, /data-testid="active"/); + assert.doesNotMatch( + html, + /data-testid="start"|data-testid="error"| } @@ -890,6 +955,7 @@ export function AgentInstanceEditDialog({ {onEditLinkedPersona ? ( ); @@ -710,6 +722,7 @@ export function AgentInstructionReview({ } function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { + const description = effectiveAgentDescription(persona); const isCommunityEntry = isCatalogPersona(persona) && !persona.catalogSource.isOwn; const ownerPubkey = isCommunityEntry @@ -748,6 +761,15 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { + {description ? ( +

+ {description} +

+ ) : null} + onInheritHarnessChange(event.target.checked)} type="checkbox" @@ -182,6 +183,7 @@ export function EditAgentAdvancedFields({ > onAutoRestartChange(event.target.checked)} type="checkbox" @@ -344,6 +346,7 @@ export function EditAgentAdvancedFields({ {numericDescriptors.length > 0 ? ( { @@ -361,6 +364,7 @@ export function EditAgentAdvancedFields({ {/* Effort-tuning knob — only shown for buzz-agent. */} {isBuzzAgentRuntime(modelTuningRuntimeId) ? ( void; + isCustomProviderEditing: boolean; + provider: string; + onProviderChange: (value: string) => void; + topLevelSecretEnvVar: string | null; + apiKeyIsInherited: boolean; + apiKeyInheritedLabel: string; + apiKeyIsRequired: boolean; + effectiveProvider: string; + apiKeyValue: string; + onApiKeyChange: (value: string) => void; + modelRequired: boolean; + modelDiscoveryLoading: boolean; + modelDropdownOptions: PersonaDropdownOption[]; + modelSelectValue: string; + onModelDropdownChange: (value: string) => void; + showCustomModelInput: boolean; + model: string; + onModelChange: (value: string) => void; + modelStatusMessage: string | null; +}) { + return ( + <> + {/* LLM provider */} + {llmProviderFieldVisible ? ( +
+ + + {isCustomProviderEditing ? ( +
+ onProviderChange(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> +
+ ) : null} +
+ ) : null} + + {llmProviderFieldVisible && topLevelSecretEnvVar ? ( + + ) : null} + + {/* Model */} +
+ + + {showCustomModelInput ? ( +
+ onModelChange(event.target.value)} + placeholder="Custom model ID" + value={model} + /> +
+ ) : null} + {modelStatusMessage ? ( +

{modelStatusMessage}

+ ) : null} +
+ + ); +} diff --git a/desktop/src/features/agents/ui/EffortPickerField.tsx b/desktop/src/features/agents/ui/EffortPickerField.tsx index a06f17ac11f..cafe9921b28 100644 --- a/desktop/src/features/agents/ui/EffortPickerField.tsx +++ b/desktop/src/features/agents/ui/EffortPickerField.tsx @@ -1,7 +1,3 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks"; -import { persistAgentEffortLevel } from "@/shared/api/tauriManagedAgents"; import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types"; import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions"; import { @@ -11,40 +7,41 @@ import { import { PersonaDropdownField } from "./PersonaDropdownField"; /** - * Thinking-effort write control for the edit dialog (B5, v4 direct-write). + * Thinking-effort write control for the edit dialog. * - * Local-only by construction: the write calls `persistAgentEffortLevel`, which - * the Rust command rejects for non-local backends (remote effort is set at - * deploy time via `policy_env`). So the control renders only for a local - * backend AND once the adapter has advertised a `thought_level` configId - * (discovered from the running session — absent pre-first-session and for - * runtimes/models without effort support). The read-only configured-vs-running - * two-facts display lives in `AgentConfigPanel`; this is the write control. + * Local-only by construction: the Rust backend rejects effort writes for + * non-local backends (remote effort is set at deploy time via `policy_env`). So the + * control renders only for a local backend AND once the adapter has advertised + * a `thought_level` configId (discovered from the running session — absent + * pre-first-session and for runtimes/models without effort support). The + * read-only configured-vs-running two-facts display lives in `AgentConfigPanel`; + * this is the write control. * - * Direct-write: each selection persists immediately and invalidates the config - * surface so the panel's canonical tier reflects the new next-spawn value. + * Save-gated, not direct-write: the control is fully controlled by the parent + * dialog (`value`/`onChange`) and owns no mutation. The dialog persists the + * selection by embedding `effortLevel` in the locked `update_managed_agent` + * call (PR #4625), so the effort write is atomic with any access-policy change + * and can never race or survive a Cancel/failed Save. */ export function EffortPickerField({ agent, config, + disabled, + value, + onChange, }: { agent: ManagedAgent; config: RuntimeConfigSurface | undefined; + disabled: boolean; + /** The pending persisted effort form (`null` = adapter default). */ + value: string | null; + onChange: (level: string | null) => void; }) { - const queryClient = useQueryClient(); - const mutation = useMutation({ - mutationFn: (level: string | null) => - persistAgentEffortLevel(agent.pubkey, level), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: agentConfigSurfaceQueryKey(agent.pubkey), - }), - }); const { visible, options, selectValue } = effortPickerState({ backend: agent.backend, effortConfigId: config?.effortConfigId, effortOptions: config?.effortOptions, - currentEffort: config?.normalized.thinkingEffort?.value ?? null, + currentEffort: value, }); if (!visible) { @@ -61,10 +58,10 @@ export function EffortPickerField({ Optional - mutation.mutate(effortSelectionToPersistedValue(value)) + onValueChange={(next) => + onChange(effortSelectionToPersistedValue(next)) } options={options} placeholder="Adapter default" @@ -73,9 +70,6 @@ export function EffortPickerField({

Applied at the next session start.

- {mutation.error instanceof Error ? ( -

{mutation.error.message}

- ) : null} ); } diff --git a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx index 0381ebc03ba..7b09ca5caf6 100644 --- a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx +++ b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx @@ -39,7 +39,7 @@ export function IdentityInitialsAvatar({ return ( & { + agent: Pick & { + status: ManagedAgent["status"] | "unknown"; avatarUrl?: string | null; }; autoTail?: boolean; @@ -76,7 +76,7 @@ export function ManagedAgentSessionPanel({ rawEventsOverride, transcriptOverride, }: ManagedAgentSessionPanelProps) { - const hasObserver = isManagedAgentActive(agent); + const hasObserver = agent.status === "running" || agent.status === "deployed"; // Always read from the store — archived frames are ingested regardless of // live status and must be renderable for idle agents with channel history. // The `hasObserver` flag still gates the relay subscription (via the diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 0bc6f9646af..cd10e8704c7 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -157,6 +157,12 @@ export function ModelPicker({ try { if (isLiveSwitch) { const outcome = await sendLiveSwitch(modelId); + if (outcome === "ambiguous") { + toast.error( + "Couldn't switch all sessions — a channel has multiple agent sessions. Stop and restart the agent with the new model.", + ); + return; + } if (outcome === "unsupported") { toast.error("That model isn't available for this agent."); return; diff --git a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx index 51367bb855c..11b7eeed3b0 100644 --- a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx +++ b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx @@ -1,8 +1,10 @@ import { Cloud } from "lucide-react"; +import { useIsOtherSetupAgent } from "../useKnownAgentPubkeys"; + import { cn } from "@/shared/lib/cn"; -const OTHER_SETUP_LABEL = "From another Dreamforge setup"; +const OTHER_SETUP_LABEL = "Not managed on this device"; export function OtherSetupAgentMarker({ className, @@ -23,3 +25,21 @@ export function OtherSetupAgentMarker({ ); } + +/** Connected marker for identity details; shares the app's directory subscriptions. */ +export function AgentManagementMarker({ + pubkey, + ownerPubkey, + className, + testId, +}: { + pubkey?: string | null; + ownerPubkey?: string | null; + className?: string; + testId?: string; +}) { + const show = useIsOtherSetupAgent(pubkey, ownerPubkey); + return show ? ( + + ) : null; +} diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 589d4c8c7ad..400f93af7c5 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -419,6 +419,7 @@ function AllowlistPicker({
diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 695504429dc..1796adaa6b7 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -295,6 +295,7 @@ export function TeamDialog({ avatarUrl={persona.avatarUrl} className="h-6 w-6 text-2xs" label={persona.displayName} + shape="squircle" /> {persona.displayName} {persona.isBuiltIn ? ( diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index 8e4b02c9e8d..45931d143aa 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -120,7 +120,7 @@ function TeamAvatarRow({ if (visiblePersonas.length === 0 && overflowCount === 0) { return (
-
+
@@ -135,19 +135,14 @@ function TeamAvatarRow({ role="img" > {visiblePersonas.map((persona, index) => ( - + ))} {overflowCount > 0 ? (
0 ? "-ml-5" : ""} style={{ zIndex: stackItemCount }} > - + +{overflowCount}
@@ -159,44 +154,40 @@ function TeamAvatarRow({ function TeamAvatarItem({ index, - isFollowedByAnother, persona, }: { index: number; - isFollowedByAnother: boolean; persona: AgentPersona; }) { const avatarUrl = persona.avatarUrl?.trim() ?? null; return (
0 ? "-ml-5" : ""}`} + className={`relative h-14 w-14 before:absolute before:-inset-0.5 before:rounded-[calc(30%+2px)] before:bg-card before:content-[''] ${index > 0 ? "-ml-5" : ""}`} data-team-member-avatar="avatar" style={{ zIndex: index + 1, - ...(isFollowedByAnother && { - mask: "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", - WebkitMask: - "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", - }), }} > - {avatarUrl ? ( - - ) : ( - - )} +
+ {avatarUrl ? ( + + ) : ( + + )} +
); } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index d0ff2e2738a..0aac9be9a1d 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -6,7 +6,9 @@ import { resolveAgentCardAvatarUrl, } from "@/features/agents/lib/agentCardAvatar"; import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; +import { effectiveAgentDescription } from "@/features/agents/lib/agentDescription"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; +import type { AgentAvailabilityReader } from "@/features/agents/lib/useAgentAvailability"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; @@ -15,6 +17,10 @@ import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { Badge } from "@/shared/ui/badge"; +import { + ProtectedBestieCardBadge, + useProtectedBestiePubkey, +} from "@protected-feature-components"; import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { AgentIdentityCard } from "./AgentIdentityCard"; import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl"; @@ -24,6 +30,7 @@ import { buildUnifiedGroups } from "./unifiedAgentGroups"; type UnifiedAgentsSectionProps = { defaultModel: string; + getAvailability: AgentAvailabilityReader; actionErrorMessage: string | null; actionNoticeMessage: string | null; agents: ManagedAgent[]; @@ -69,6 +76,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { actionErrorMessage, actionNoticeMessage, defaultModel, + getAvailability, agents, agentsError, isActionPending, @@ -96,6 +104,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { } = props; const isArchived = useIsArchivedPredicate(); + const bestiePubkey = useProtectedBestiePubkey(agents)?.toLowerCase() ?? null; const { groups, ungrouped, unknown } = React.useMemo( () => buildUnifiedGroups(personas, agents, isArchived), [personas, agents, isArchived], @@ -152,7 +161,9 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { /> )} agent={profileAgent} + getAvailability={getAvailability} defaultModel={defaultModel} + isBestie={profileAgent?.pubkey.toLowerCase() === bestiePubkey} key={group.persona.id} persona={group.persona} restartingAgentPubkey={restartingAgentPubkey} @@ -172,8 +183,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { React.ReactNode; agent: ManagedAgent | undefined; defaultModel: string; + isBestie: boolean; + getAvailability: AgentAvailabilityReader; persona: AgentPersona; restartingAgentPubkey: string | null; startingAgentPubkey: string | null; @@ -252,13 +271,18 @@ function AgentPersonaCard({ onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; }) { + const availability = getAvailability(agent?.pubkey); const title = persona.displayName; - const modelLabel = resolveAgentCardModelLabel({ - agent, - personaModel: persona.model, - provider: persona.provider, - defaultModel, - }); + // Card face second line: the authored description when one exists; + // otherwise fall back to the model label as before. + const subtitle = + effectiveAgentDescription(persona) ?? + resolveAgentCardModelLabel({ + agent, + personaModel: persona.model, + provider: persona.provider, + defaultModel, + }); const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent @@ -283,6 +307,7 @@ function AgentPersonaCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + availability={availability} isRestarting={restartingAgentPubkey === agent.pubkey} isStarting={startingAgentPubkey === agent.pubkey} label={title} @@ -311,8 +336,13 @@ function AgentPersonaCard({ } avatarUrl={avatarUrl} dataTestId={`persona-agent-row-${persona.id}`} + footerAccessory={ + agent ? ( + + ) : null + } label={title} - modelLabel={modelLabel} + subtitle={subtitle} onClick={() => { // The card's main click always opens the PERSONA target, never an // explicit pubkey. A pubkey target is durable in the panel, so a pick @@ -339,7 +369,9 @@ function AgentPersonaCard({ function StandaloneAgentCard({ agent, + isBestie, defaultModel, + getAvailability, restartingAgentPubkey, startingAgentPubkey, onOpenAgentProfile, @@ -347,7 +379,9 @@ function StandaloneAgentCard({ onStartAgent, }: { agent: ManagedAgent; + isBestie: boolean; defaultModel: string; + getAvailability: AgentAvailabilityReader; restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onOpenAgentProfile: ( @@ -357,6 +391,7 @@ function StandaloneAgentCard({ onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; }) { + const availability = getAvailability(agent.pubkey); const title = agent.name; const profileQuery = useUserProfileQuery(agent.pubkey); const friendlyError = friendlyAgentLastError( @@ -376,6 +411,7 @@ function StandaloneAgentCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + availability={availability} isRestarting={restartingAgentPubkey === agent.pubkey} isStarting={startingAgentPubkey === agent.pubkey} label={title} @@ -393,13 +429,20 @@ function StandaloneAgentCard({ } avatarUrl={profileQuery.data?.avatarUrl} dataTestId={`managed-agent-${agent.pubkey}`} + footerAccessory={ + + } label={title} - modelLabel={resolveAgentCardModelLabel({ - agent, - personaModel: null, - provider: agent.provider, - defaultModel, - })} + subtitle={ + // Definition-less instance: no authored description exists, so fall + // back to the model label. + resolveAgentCardModelLabel({ + agent, + personaModel: null, + provider: agent.provider, + defaultModel, + }) + } onClick={() => { onOpenAgentProfile( agent.pubkey, @@ -441,8 +484,10 @@ function CollapsibleAgentGroup({ groupKey, label, agents, + bestiePubkey, collapsed, defaultModel, + getAvailability, restartingAgentPubkey, startingAgentPubkey, onToggle, @@ -453,8 +498,10 @@ function CollapsibleAgentGroup({ groupKey: string; label: string; agents: ManagedAgent[]; + bestiePubkey: string | null; collapsed: ReadonlySet; defaultModel: string; + getAvailability: AgentAvailabilityReader; restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onToggle: (key: string) => void; @@ -486,7 +533,9 @@ function CollapsibleAgentGroup({ {agents.map((agent) => ( a.pubkey), + ); + return createElement(UnifiedAgentsSection, { ...props, getAvailability }); +} + function renderSection(props) { const client = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { gcTime: 0 }, + }, }); clients.push(client); return render( createElement( QueryClientProvider, { client }, - createElement(UnifiedAgentsSection, props), + createElement(Surface, props), ), ); } @@ -157,6 +168,9 @@ before(async () => { "@tanstack/react-query" )); ({ UnifiedAgentsSection } = await import("./UnifiedAgentsSection.tsx")); + ({ useAgentAvailabilityLookup } = await import( + "../lib/useAgentAvailability.ts" + )); }); afterEach(() => { @@ -293,3 +307,300 @@ test("errored avatar affordance still opens the explicit pubkey on the runtime t assert.deepEqual(opened, [{ pubkey: LIVE_PK, options: { tab: "runtime" } }]); }); + +for (const kind of ["persona", "custom", "unknown"]) { + test(`${kind} stopped card uses exact-key presence without inventing lifecycle controls`, async () => { + installFailOpenIpc(); + const { relayClient } = await import("../../../shared/api/relayClient.ts"); + const originalConnection = relayClient.getConnectionState; + const originalSubscribe = relayClient.subscribeToConnectionState; + relayClient.getConnectionState = () => "connected"; + relayClient.subscribeToConnectionState = () => () => {}; + let snapshot = { [ARCHIVED_PK]: "online" }; + ipcHandlers.set("get_presence", () => Promise.resolve(snapshot)); + const starts = []; + const props = baseProps({ + agents: [ + agent({ + personaId: + kind === "custom" + ? null + : kind === "unknown" + ? "missing" + : "persona-1", + }), + ], + personas: kind === "persona" ? [persona()] : [], + onStartAgent: (key) => starts.push(key), + onRestartAgent: () => { + throw new Error("presence must not cause Restart"); + }, + }); + try { + await act(async () => renderSection(props)); + const client = clients.at(-1); + const refresh = async () => { + await act(async () => { + await client.invalidateQueries({ queryKey: ["presence"] }); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + }; + await refresh(); + // Different-key Online must not suppress this identity's ordinary Start. + fireEvent.click(screen.getByTestId(`agent-runtime-start-${LIVE_PK}`)); + assert.deepEqual(starts, [LIVE_PK]); + starts.length = 0; + for (const status of ["online", "away", "offline", "online"]) { + snapshot = { [LIVE_PK]: status }; + await refresh(); + const start = screen.queryByTestId(`agent-runtime-start-${LIVE_PK}`); + if (status === "offline") { + assert.ok(start); + fireEvent.click(start); + assert.deepEqual(starts, [LIVE_PK]); + starts.length = 0; + } else { + assert.equal( + Boolean(start), + false, + "active exact-key presence must remove Start", + ); + const dot = screen.getByTestId(`agent-runtime-active-${LIVE_PK}`); + assert.match( + dot.getAttribute("aria-label"), + new RegExp(status === "online" ? "Online$" : "Away$"), + ); + fireEvent.click(dot); + fireEvent.keyDown(dot, { key: "Enter" }); + fireEvent.keyDown(dot, { key: " " }); + assert.deepEqual(starts, []); + assert.equal( + Boolean(screen.queryByRole("button", { name: /Stop/ })), + false, + ); + } + } + // A successful omitted entry is the existing relay expiry/missing path. + snapshot = {}; + await refresh(); + fireEvent.click(screen.getByTestId(`agent-runtime-start-${LIVE_PK}`)); + assert.deepEqual(starts, [LIVE_PK]); + } finally { + relayClient.getConnectionState = originalConnection; + relayClient.subscribeToConnectionState = originalSubscribe; + } + }); +} + +test("N cards share a snapshot, one poll, failure recovery and live subscription lifecycle", async (t) => { + installFailOpenIpc(); + const { relayClient } = await import("../../../shared/api/relayClient.ts"); + const { usePresenceSubscription, useSetPresenceMutation } = await import( + "../../presence/hooks.ts" + ); + const original = { + getConnectionState: relayClient.getConnectionState, + subscribeToConnectionState: relayClient.subscribeToConnectionState, + subscribeToReconnects: relayClient.subscribeToReconnects, + subscribeLive: relayClient.subscribeLive, + sendPresence: relayClient.sendPresence, + }; + const subscriptions = []; + const requests = []; + let fail = false; + let finishSnapshot; + ipcHandlers.set("get_presence", ({ pubkeys }) => { + requests.push(pubkeys); + if (fail) return Promise.reject("relay unreachable: request timed out"); + return new Promise((resolve) => { + finishSnapshot = resolve; + }); + }); + relayClient.sendPresence = async () => {}; + relayClient.getConnectionState = () => "connected"; + relayClient.subscribeToConnectionState = () => () => {}; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.subscribeLive = async (filter, onEvent, onReady) => { + const sub = { filter, onEvent, closed: false }; + subscriptions.push(sub); + onReady("eose"); + return async () => { + sub.closed = true; + }; + }; + Object.defineProperty(dom.window.document, "visibilityState", { + configurable: true, + value: "visible", + }); + const originalFocus = dom.window.document.hasFocus; + dom.window.document.hasFocus = () => true; + t.mock.timers.enable({ apis: ["setInterval"] }); + let setPresence; + function SubscribedSurface(props) { + setPresence = useSetPresenceMutation(SELF_PK); + usePresenceSubscription(); + return createElement(Surface, props); + } + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { gcTime: 0 }, + }, + }); + clients.push(client); + const props = baseProps({ + agents: [ + agent({ pubkey: LIVE_PK, status: "running" }), + agent({ pubkey: ARCHIVED_PK, personaId: null, status: "running" }), + agent({ pubkey: SELF_PK, personaId: "missing", status: "running" }), + ], + personas: [persona()], + }); + const tree = (next) => + createElement( + QueryClientProvider, + { client }, + createElement(SubscribedSurface, next), + ); + const settle = async () => + act(async () => { + await new Promise((r) => setTimeout(r, 120)); + }); + try { + let view; + await act(async () => { + view = render(tree(props)); + }); + assert.equal( + requests.length, + 1, + "one in-flight request for persona/custom/unknown rows", + ); + await act(async () => { + view.rerender(tree({ ...props, agents: [...props.agents].reverse() })); + }); + assert.equal( + requests.length, + 1, + "reordering while the snapshot is pending does not refetch", + ); + await act(async () => { + finishSnapshot({}); + }); + await settle(); + assert.deepEqual(requests[0], [ARCHIVED_PK, LIVE_PK, SELF_PK]); + assert.equal(subscriptions.length, 1); + assert.deepEqual(subscriptions[0].filter.authors, requests[0]); + assert.equal( + client + .getQueryCache() + .find({ queryKey: ["presence", ...requests[0]] }) + .getObserversCount(), + 1, + ); + await act(async () => { + subscriptions[0].onEvent({ pubkey: LIVE_PK, content: "away" }); + }); + await settle(); + assert.match( + screen + .getByTestId(`agent-runtime-active-${LIVE_PK}`) + .getAttribute("aria-label"), + /Away$/, + ); + assert.match( + screen + .getByTestId(`agent-runtime-active-${ARCHIVED_PK}`) + .getAttribute("aria-label"), + /Offline$/, + ); + assert.equal( + requests.length, + 1, + "live exact-key update makes no snapshot requests", + ); + fail = true; + await act(async () => { + t.mock.timers.tick(60000); + }); + await settle(); + assert.equal(requests.length, 2, "one backstop poll, not one per card"); + for (const { pubkey } of props.agents) { + assert.match( + screen + .getByTestId(`agent-runtime-active-${pubkey}`) + .getAttribute("aria-label"), + /Availability unknown$/, + ); + } + await act(async () => { + subscriptions[0].onEvent({ pubkey: ARCHIVED_PK, content: "online" }); + }); + await settle(); + for (const { pubkey } of props.agents) { + assert.match( + screen + .getByTestId(`agent-runtime-active-${pubkey}`) + .getAttribute("aria-label"), + /Availability unknown$/, + "one live author must not resurrect a failed aggregate's cached siblings", + ); + } + await act(async () => { + await setPresence.mutateAsync("online"); + }); + await settle(); + for (const { pubkey } of props.agents) { + assert.match( + screen + .getByTestId(`agent-runtime-active-${pubkey}`) + .getAttribute("aria-label"), + /Availability unknown$/, + "a successful self heartbeat must not heal a failed aggregate snapshot", + ); + } + fail = false; + await act(async () => { + t.mock.timers.tick(60000); + }); + await act(async () => { + finishSnapshot({}); + }); + await settle(); + assert.equal(requests.length, 3); + assert.match( + screen + .getByTestId(`agent-runtime-active-${LIVE_PK}`) + .getAttribute("aria-label"), + /Offline$/, + ); + await act(async () => { + view.rerender(tree({ ...props, agents: [props.agents[0]] })); + }); + await act(async () => { + finishSnapshot({}); + }); + await settle(); + assert.deepEqual(subscriptions[1].filter.authors, [LIVE_PK]); + assert.equal(subscriptions[0].closed, true); + await act(async () => { + view.unmount(); + }); + assert.equal( + subscriptions[1].closed, + true, + "last surface removes its live subscription", + ); + const count = requests.length; + await act(async () => { + t.mock.timers.tick(120000); + }); + assert.equal(requests.length, count, "unmounted surfaces do not poll"); + } finally { + t.mock.timers.reset(); + dom.window.document.hasFocus = originalFocus; + Object.assign(relayClient, original); + } +}); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 181e4febf5c..470dd1b894e 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -101,7 +101,10 @@ export function UserMessageBubble({ {isCompactPreview ? null : item.authorPubkey && openProfilePanel ? ( @@ -123,6 +127,7 @@ export function UserMessageBubble({ avatarUrl={authorProfile?.avatarUrl ?? null} className="order-last ml-2 mt-1 size-7 shrink-0 text-xs" displayName={authorLabel} + shape={authorProfile?.isAgent ? "squircle" : "circle"} size="sm" /> )} diff --git a/desktop/src/features/agents/ui/agentConfigControls.tsx b/desktop/src/features/agents/ui/agentConfigControls.tsx index 1a431d1f914..677db669a34 100644 --- a/desktop/src/features/agents/ui/agentConfigControls.tsx +++ b/desktop/src/features/agents/ui/agentConfigControls.tsx @@ -339,7 +339,6 @@ export function AgentModelField({ allowDefaultModel = true, defaultModelLabel, disableSelectDuringDiscovery = true, - keepSelectedModelValueLabel = false, id = "agent-model", isCustomModelEditing, isRequired, @@ -371,8 +370,6 @@ export function AgentModelField({ defaultModelLabel?: string; /** Disable the trigger while live model discovery refreshes the option list. */ disableSelectDuringDiscovery?: boolean; - /** Keep the closed trigger from swapping to discovered display labels. */ - keepSelectedModelValueLabel?: boolean; /** DOM id for the model select. Defaults to `"agent-model"`. Override in * contexts where multiple instances coexist on the same page (e.g. the * global-config settings card) to avoid duplicate DOM ids. */ @@ -513,12 +510,6 @@ export function AgentModelField({ // yields an empty list and discovery has finished, add a disabled sentinel // row so the user sees "No models found" instead of a bare white bar. appendNoModelsSentinel(modelOptions, modelDiscoveryLoading); - const stableSelectedModelLabel = - keepSelectedModelValueLabel && - modelSelectValue === trimmedModel && - trimmedModel.length > 0 - ? trimmedModel - : undefined; // While discovery is in flight with nothing selected, the closed field // reads "Loading models…" instead of a select-prompt — the field isn't // waiting on the user, it's waiting on the harness. @@ -547,7 +538,6 @@ export function AgentModelField({ placeholder={restingPlaceholder} placeholderClassName={placeholderClassName} searchable - selectedLabel={stableSelectedModelLabel} testId={testId ?? id} value={modelSelectValue} /> diff --git a/desktop/src/features/agents/ui/agentDefaultsEditor.test.mjs b/desktop/src/features/agents/ui/agentDefaultsEditor.test.mjs new file mode 100644 index 00000000000..262f2a67f08 --- /dev/null +++ b/desktop/src/features/agents/ui/agentDefaultsEditor.test.mjs @@ -0,0 +1,705 @@ +/** + * Real-parent Save/Next journeys: AgentDefaultsEditor and DefaultConfigStep + * exercise the complete effort write→save→reread contract through the + * production component trees that users actually encounter. + * + * Finding 2 (PR #4625): effortAutoClear.test.mjs tests AgentConfigFields + * directly via a hand-rolled SettingsParent. These tests mount the real parents + * to confirm the same invariants hold through the production entry points. + * + * AgentDefaultsEditor (Settings surface): + * - Loads config via `get_global_agent_config` IPC on mount. + * - Selects harness from the ACP runtime cache (QueryClientProvider). + * - Renders AgentConfigFields with useCustomSelect=true. + * - Zero IPC writes on mount and before Save (Save-gated contract). + * - Operate the effort control: click the real Popover trigger, select "off" + * from the option list. Assert zero writes after selection. + * - Exactly one `set_global_agent_config` write fires on "Save defaults" click. + * - The save stub captures the submitted payload; asserts raw + * GOOSE_THINKING_EFFORT: "off" is present. + * - After save the stub stores its canonical response (from the actual + * submitted payload). A fresh mount hydrated from that stored response + * shows data-value="off" and text "Off". + * + * DefaultConfigStep (onboarding surface): + * - Same contract through the onboarding parent tree and the "Next" button. + * - Draft starts with isDirty=false. The form is dirtied by operating the + * real effort control (click trigger → select "off"), which calls + * onConfigChange → updateDraft → sets isDirtyRef=true. + * - Zero writes on mount and after effort selection. + * - Exactly one write fires on "Next" click (commit() is a no-op when + * !isDirty, so real-control dirtying is load-bearing here). + * - Same payload capture + stored canonical + fresh remount contract. + * + * Mutation proofs: + * - Removing isHarnessNativeEffort branch in AgentConfigFields → effort + * custom trigger shows inherit placeholder instead of "Off" on mount and + * after remount → mount and remount assertions RED. + * - Removing the Save-gate (firing set_global_agent_config outside of a + * Save/Next click) → write-count-before-save assertion fails → RED. + * - Dropping GOOSE_THINKING_EFFORT from the submitted payload → payload + * assertion fails → RED. + * - In the onboarding test: removing the effort-select dirtying steps (so + * isDirty stays false) → commit() is a no-op → write-count assertion after + * Next fails (0 instead of 1) → RED. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// ── Global env setup ───────────────────────────────────────────────────────── +Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + localStorage: dom.window.localStorage, + self: dom.window, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, +}); +dom.window.requestAnimationFrame = (cb) => setTimeout(cb, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +dom.window.matchMedia ??= (query) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, +}); +globalThis.matchMedia = dom.window.matchMedia; +for (const key of Object.getOwnPropertyNames(dom.window)) { + if (key === "window" || key === "document" || key === "globalThis") continue; + const value = dom.window[key]; + if ( + typeof value === "function" && + /^(HTML|SVG)|Element$|Event$|EventTarget$|^Node|^Document|Observer$/.test( + key, + ) + ) { + globalThis[key] = value; + } +} +globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); +const _origDispatch = dom.window.EventTarget.prototype.dispatchEvent; +dom.window.EventTarget.prototype.dispatchEvent = function (event) { + if (!(event instanceof dom.window.Event)) return false; + return _origDispatch.call(this, event); +}; +globalThis.EventTarget = dom.window.EventTarget; + +// ── QueryClient tracking ────────────────────────────────────────────────────── +// react-query's default gcTime schedules timers that outlive each test and +// stall the process. Track every client; cancel + clear in afterEach. +const clients = []; + +// ── IPC write tracking ──────────────────────────────────────────────────────── +// saveCallCount: total set_global_agent_config calls. +// capturedSavePayload: exact config submitted in the most recent Save/Next. +// storedCanonicalResponse: canonical response the stub computed from the Save +// payload; returned by get_global_agent_config on the fresh remount. +let saveCallCount = 0; +let capturedSavePayload = null; +let storedCanonicalResponse = null; + +// ── Tauri IPC stub ──────────────────────────────────────────────────────────── +const DEFAULT_CONFIG = { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: "goose", +}; + +function makeIpcHandler(overrides = {}) { + return (cmd, payload) => { + if (cmd in overrides) return overrides[cmd](payload); + if (cmd === "get_global_agent_config") + return Promise.resolve(DEFAULT_CONFIG); + if (cmd === "set_global_agent_config") { + saveCallCount += 1; + // Capture the submitted config and compute the canonical response by + // echoing the payload (the server's canonical form is what was saved). + capturedSavePayload = payload?.config ?? null; + storedCanonicalResponse = capturedSavePayload ?? DEFAULT_CONFIG; + return Promise.resolve({ + config: storedCanonicalResponse, + restarted_count: 0, + failed_restart_count: 0, + }); + } + if (cmd === "get_baked_build_env" || cmd === "get_baked_build_env_keys") + return Promise.resolve([]); + if (cmd === "discover_acp_providers") + return Promise.resolve([rawGooseCatalogEntry()]); + if (cmd === "discover_agent_models") + return Promise.resolve({ options: [], is_optional: true }); + if (cmd === "get_runtime_file_config") return Promise.resolve(null); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }; +} + +globalThis.__TAURI_INTERNALS__ = { + invoke: makeIpcHandler(), + transformCallback: () => 1, +}; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; + +// ── Deferred imports ────────────────────────────────────────────────────────── +let act, render, screen, cleanup, fireEvent, createElement; +let AgentDefaultsEditor; +let DefaultConfigStep; +let QueryClient, QueryClientProvider; +let acpRuntimesQueryKey, fromRawAcpRuntimeCatalogEntry; + +before(async () => { + ({ act, render, screen, cleanup, fireEvent } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ AgentDefaultsEditor } = await import("./AgentDefaultsEditor.tsx")); + ({ DefaultConfigStep } = await import( + "../../onboarding/ui/DefaultConfigStep.tsx" + )); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ acpRuntimesQueryKey } = await import( + "@/features/agents/acpRuntimesQuery.ts" + )); + ({ fromRawAcpRuntimeCatalogEntry } = await import("@/shared/api/tauri.ts")); +}); + +afterEach(() => { + cleanup?.(); + for (const client of clients.splice(0)) { + client.cancelQueries(); + client.clear(); + } + // Reset write tracking and restore default IPC stub. + saveCallCount = 0; + capturedSavePayload = null; + storedCanonicalResponse = null; + globalThis.__TAURI_INTERNALS__.invoke = makeIpcHandler(); + dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; +}); + +after(() => dom.window.close()); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** Minimal raw Goose catalog entry with effort_canonical_values. */ +function rawGooseCatalogEntry() { + return { + id: "goose", + label: "Goose", + avatar_url: "", + availability: "available", + command: "goose", + binary_path: "/usr/local/bin/goose", + default_args: [], + mcp_command: null, + model_env_var: "GOOSE_MODEL", + provider_env_var: "GOOSE_PROVIDER", + thinking_env_var: "GOOSE_THINKING_EFFORT", + max_tokens_env_var: null, + context_limit_env_var: null, + max_rounds_env_var: null, + install_hint: "", + install_instructions_url: "", + can_auto_install: false, + requires_external_cli: false, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "not_applicable" }, + login_hint: null, + source: "builtin", + effort_canonical_values: ["off", "low", "medium", "high", "max"], + }; +} + +function makeQueryClient() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + clients.push(client); + return client; +} + +function seedGooseRuntime(queryClient) { + const entry = fromRawAcpRuntimeCatalogEntry(rawGooseCatalogEntry()); + queryClient.setQueryData(acpRuntimesQueryKey, [entry]); + return entry; +} + +function withQueryClient(client, children) { + return createElement(QueryClientProvider, { client }, children); +} + +/** Drain React update queue. */ +async function settle() { + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + await act(async () => {}); +} + +/** + * Select an effort value through the real Popover-based custom select. + * Clicks the trigger button to open the popover, then clicks the option button. + * The AgentDropdownSelect is a controlled Popover + listbox, not a native + * { + const next = Number(event.currentTarget.value); + if (audioRef.current && Number.isFinite(next)) { + audioRef.current.currentTime = next; + setCurrentTime(next); + paintProgress(next, Number(event.currentTarget.max)); + } + }} + step="0.01" + type="range" + value={Math.min(currentTime, Math.max(duration, 0.01))} + /> +
+ )} + + + + {timeLabel} + + {!composer ? ( + + ) : null} + + {!composer && downloadUrl ? ( + + { + invokeTauri("download_file", { + filename, + url: downloadUrl, + }).catch((error: unknown) => { + toast.error( + error instanceof Error ? error.message : "Download failed", + ); + }); + }} + title="Download" + type="button" + > + + + + ) : null} + {!composer && onRemove ? ( + + + + + + ) : null} + {/* biome-ignore lint/a11y/useMediaCaption: voice notes are user-provided audio */} +