diff --git a/.env.example b/.env.example index 02e907b8cf..6d12738247 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.yml b/.github/workflows/ci.yml index 25a59c3243..004051f5b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -304,7 +304,7 @@ jobs: name: Desktop runs-on: ubuntu-latest timeout-minutes: 5 - needs: [changes, desktop-core, desktop-smoke-e2e] + needs: [changes, desktop-core, desktop-smoke-e2e, desktop-windows-build] 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 @@ -319,6 +319,10 @@ jobs: 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-e2e-relay: @@ -380,6 +384,7 @@ jobs: -p buzz-relay \ -p buzz-test-client \ --lib \ + --bin buzz-relay \ --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache @@ -684,6 +689,18 @@ jobs: VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" + - name: Workflow message provenance tests + # The relay's workflow_sink suite is not selected by the infra-free + # unit job. Run both its pure tests and ignored PostgreSQL tests here so + # authored-template provenance cannot regress behind a green CI build. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/workflow_sink/)' \ + --run-ignored all + 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: Replaceable persistence PostgreSQL tests # Transaction, concurrency, and mention-index coverage for the # replaceable-event store seam. These tests require real Postgres and @@ -709,6 +726,31 @@ jobs: 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: Writer session timeout guardrails + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(session_timeouts_install_through_db_new_and_bound_lock_waits)' \ + --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: Audit writer session timeout guardrails + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Audit worker lock-timeout recovery + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(audit_worker_retries_lock_timeout_until_original_entry_is_appended_once)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Start relay run: | chmod +x ./target/ci/buzz-relay @@ -1095,6 +1137,36 @@ jobs: -p git-credential-nostr \ -p git-sign-nostr + desktop-windows-build: + name: Desktop Windows Build + runs-on: windows-latest + timeout-minutes: 20 + 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: 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 } + windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) runs-on: windows-latest diff --git a/.github/workflows/codex-security-review.yml b/.github/workflows/codex-security-review.yml index df558a877d..68ff0553c0 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 @@ -228,7 +228,7 @@ jobs: 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 24115501ad..4395283b0f 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 892082d96c..138d192fa1 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 diff --git a/Justfile b/Justfile index f7f29f4655..ce2abad346 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 @@ -380,6 +424,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 0e64b74066..0e4aee8784 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 diff --git a/benchmarks/buzz-dataset/README.md b/benchmarks/buzz-dataset/README.md index cfce3b257a..6df3fbadcf 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 0000000000..ef6e63950f --- /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 0000000000..29f16f3c41 --- /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 0000000000..0a7a96173d --- /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 0000000000..a018303ce5 --- /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 0000000000..79434035e3 --- /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 0000000000..e3ffe9f5c6 --- /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 df859b41ab..a0bd08a6ae 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 797e3a860c..a29b86e731 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 @@ class BuzzContainerRuntime: "--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 @@ class BuzzContainerRuntime: 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 451b97f9c1..87cffbbcf2 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 @@ NARRATIVE_AGENT_NAMES_TASK = "narrative-agent-names" 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 @@ _AMBIGUOUS_USER_MENTION_FIXTURE = 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 @@ _FIXTURES = { 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 f8d2a560bf..a4a460d708 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 0000000000..0403481648 --- /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 0644df63b3..de91726e42 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 814f4d3527..543499b315 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" +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/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } +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/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" }, + { 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 182db9893f..ecdc9e4cde 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 225cb1d1fa..f39da50a3b 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 @@ import importlib.util 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 05072d81a8..67b6390f36 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" +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/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } +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/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" }, + { 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 0000000000..383f4511d4 --- /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 0000000000..432f25e505 --- /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 e6164b02dd..cf36111a93 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. | -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. +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. -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. +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: + +```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. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 4dc4720ed8..6cee0b603d 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. @@ -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 2d7b212832..3d4e67d0f5 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -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. @@ -536,6 +549,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>, @@ -646,6 +661,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 +697,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 +1157,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, @@ -1164,7 +1209,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 +1221,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 +1535,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, @@ -2618,6 +2665,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] @@ -2991,6 +3074,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 25c6e54905..af504a1176 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,522 @@ 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. /// -/// 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( +/// 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 { + tracing::debug!( + 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. +/// +/// 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 +1785,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 +1805,7 @@ fn handle_cancel_turn_control( serde_json::json!({ "type": "cancel_turn", "status": status, + "requestId": payload.get("requestId"), }), ); } @@ -1370,7 +1855,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 +1878,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 +2058,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 @@ -2019,6 +2512,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 +2701,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 +2766,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; // Independent of pool readiness: a never-mentioned lazy agent must still @@ -2474,10 +2978,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); } } } @@ -2526,10 +3030,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); } } @@ -2720,7 +3224,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 @@ -2792,21 +3298,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. } @@ -2830,28 +3351,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. } @@ -2867,125 +3404,75 @@ 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 { - tracing::debug!( - channel_id = %buzz_event.channel_id, - author = %buzz_event.event.pubkey.to_hex(), - mode = %config.respond_to, - is_dm, - "inbound author gate — dropping event" - ); - 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; - } + 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 { + 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, - }); + let Some(ingress) = + AuthorizedNormalListenerEvent(authorized_event) + .match_subscription(&rules, &pubkey_hex) + .await + else { + tracing::debug!("authorized event matched no rule — dropping"); + continue; + }; + // 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); } } } @@ -3082,10 +3569,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); @@ -3124,7 +3611,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(), @@ -3146,9 +3634,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, @@ -3181,10 +3671,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)) => { @@ -3206,14 +3696,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, })) => { @@ -3327,12 +3818,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, @@ -3341,18 +3828,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 @@ -3361,10 +3850,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)) => { @@ -3389,10 +3878,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) => { @@ -3589,12 +4078,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() @@ -3610,6 +4112,39 @@ fn signal_in_flight_task( false } +/// Send a control signal to the in-flight task for one exact session scope. +/// +/// 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 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: @@ -3637,11 +4172,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 @@ -3677,14 +4213,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 @@ -3702,10 +4238,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, }); @@ -3731,31 +4269,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()), @@ -3803,6 +4366,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), @@ -3810,9 +4374,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(), @@ -3898,19 +4474,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); } } @@ -4033,7 +4610,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, } @@ -4069,10 +4646,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 { @@ -4282,7 +4856,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<()>, @@ -4313,8 +4887,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; @@ -4380,7 +4969,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<()>, @@ -4451,6 +5040,7 @@ fn dispatch_heartbeat( pool::TaskMeta { agent_index, channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, @@ -5263,6 +5853,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), @@ -5289,6 +5880,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(); @@ -5366,6 +6132,305 @@ mod owner_cache_tests { } } +#[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::*; @@ -5396,12 +6461,977 @@ mod author_gate_tests { 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!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); + + 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" + ); + + 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" + ); + + 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"); + + server.abort(); + } + + #[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); + + 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(); + } + + #[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" + ); + } + + #[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] async fn test_allowlist_accepts_sibling_not_in_allowlist() { 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, @@ -5419,7 +7449,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, @@ -5437,7 +7467,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, @@ -5455,7 +7485,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, @@ -5476,7 +7506,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, @@ -5494,7 +7524,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, @@ -5520,7 +7550,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, @@ -5537,7 +7567,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, @@ -5560,7 +7590,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, @@ -5579,7 +7609,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, @@ -5719,7 +7749,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, @@ -6807,6 +8837,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, @@ -7031,6 +9062,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, @@ -7106,14 +9138,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(); @@ -7122,6 +9154,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, @@ -7148,7 +9181,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, @@ -7169,23 +9202,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(); @@ -7194,6 +9229,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, @@ -7220,7 +9256,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, @@ -7241,9 +9277,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] @@ -7251,50 +9289,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] @@ -7309,6 +9351,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, @@ -7334,7 +9377,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, @@ -7355,7 +9398,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 @@ -7374,6 +9420,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, @@ -7397,7 +9444,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, @@ -7451,6 +9500,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, @@ -7502,6 +9552,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!( @@ -7544,6 +9691,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, @@ -7565,7 +9713,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, @@ -7613,8 +9763,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(), @@ -7636,6 +9788,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, @@ -7656,7 +9809,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), @@ -7676,7 +9829,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -7722,6 +9875,7 @@ mod error_outcome_emission_tests { .unwrap(); FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7742,6 +9896,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, @@ -7762,7 +9917,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), @@ -7782,7 +9937,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -7819,6 +9974,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, @@ -7840,6 +9996,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()) @@ -7852,7 +10009,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, @@ -7914,6 +10071,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, @@ -7934,6 +10092,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()) @@ -7946,7 +10105,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, @@ -7980,7 +10139,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" ); @@ -8014,6 +10173,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(), @@ -8031,6 +10191,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, @@ -8045,6 +10206,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(), @@ -8063,7 +10225,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), @@ -8171,6 +10333,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, @@ -8193,7 +10356,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 @@ -8273,11 +10438,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(), @@ -8291,7 +10458,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( @@ -8299,6 +10466,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, @@ -8319,7 +10487,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(), @@ -8348,10 +10516,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); @@ -8425,6 +10597,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(), @@ -8448,6 +10621,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, @@ -8468,7 +10642,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), @@ -8494,7 +10668,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" ); @@ -8511,6 +10685,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(), @@ -8534,6 +10709,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, @@ -8554,7 +10730,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), @@ -8580,7 +10756,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 f18f7d6fea..4d20e30ee2 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(), } } - /// Try to claim an idle agent for the given channel (or heartbeat if `None`). + /// 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). /// - /// Pass 1: prefer an agent that already has a session for `channel_id`. + /// 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 session scope (or heartbeat if + /// `None`). + /// + /// 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, } +} - // DM non-reply: fetch recent conversation history. +/// 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, +} + +/// 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(), ); @@ -4917,6 +5132,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(), @@ -6128,8 +6349,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(), @@ -6263,7 +6486,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(); @@ -6363,14 +6586,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(); @@ -6381,6 +6604,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(), @@ -6407,7 +6631,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, @@ -6463,6 +6687,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(), @@ -6477,6 +6702,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(), @@ -6538,11 +6764,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; @@ -6584,7 +6810,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; @@ -6632,6 +6858,7 @@ done"# .unwrap(); let batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: trigger, prompt_tag: "test".into(), @@ -6691,22 +6918,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(); @@ -6769,19 +6996,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); } @@ -6790,17 +7017,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()); } @@ -6910,21 +7137,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()]), @@ -6936,23 +7163,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); } @@ -6963,29 +7409,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); @@ -7001,10 +7449,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] @@ -7024,15 +7472,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] @@ -7047,15 +7497,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); @@ -7065,7 +7515,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); @@ -7080,13 +7530,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) ───────────────────── @@ -7099,7 +7549,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, @@ -7108,8 +7558,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 ──────────────────────────────────────────── @@ -7127,6 +7577,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(), @@ -8348,14 +8799,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] @@ -8363,9 +8814,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(); @@ -8378,22 +8829,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)); } @@ -8874,6 +9325,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(), @@ -9322,7 +9774,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9359,7 +9811,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9393,7 +9845,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9426,7 +9878,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9466,7 +9918,7 @@ exit 0"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9566,6 +10018,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 @@ -9593,7 +10193,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9664,7 +10264,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9719,7 +10319,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9761,7 +10361,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9802,7 +10402,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9868,7 +10468,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9905,7 +10505,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9978,7 +10578,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -10019,7 +10619,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 d62b99114c..b2fbde6242 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()` @@ -137,24 +197,24 @@ pub struct FlushBatch { /// else: push_front with original received_at, set exponential backoff retry_after with jitter /// ``` pub struct EventQueue { - queues: HashMap>, - in_flight_channels: HashSet, - /// Per-channel deadline for auto-expiring stuck in-flight entries. - in_flight_deadlines: HashMap, + queues: HashMap>, + in_flight_scopes: HashSet, + /// 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 @@ -165,7 +225,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. @@ -181,7 +241,7 @@ impl EventQueue { pub fn new(dedup_mode: DedupMode) -> Self { Self { queues: HashMap::new(), - in_flight_channels: HashSet::new(), + in_flight_scopes: HashSet::new(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), retry_after: HashMap::new(), @@ -209,13 +269,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; @@ -230,29 +292,77 @@ 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) { tracing::debug!( channel_id = %event.channel_id, - "dropping event for in-flight channel (drop mode)" + scope = %event.scope.telemetry_label(), + "dropping event for in-flight scope (drop mode)" ); return false; } - 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(); tracing::warn!( - channel_id = %event.channel_id, - limit = MAX_PENDING_PER_CHANNEL, - "queue depth cap reached — dropped oldest event" + channel_id = %channel_id, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, + "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. @@ -263,67 +373,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, @@ -333,9 +446,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) @@ -352,29 +466,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, @@ -391,22 +504,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); } } } @@ -430,8 +544,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 }; @@ -445,10 +560,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); } @@ -474,60 +589,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 no agent was available — we want to - /// retry without penalizing the channel's position in the fairness queue - /// and without imposing a retry throttle. + /// 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. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` — + /// 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_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` @@ -542,11 +689,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 @@ -559,37 +707,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 @@ -613,27 +762,31 @@ 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. + /// 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` @@ -642,8 +795,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. @@ -658,32 +811,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) ────────────────────────── @@ -710,8 +878,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 { @@ -721,10 +890,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 @@ -740,8 +909,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 @@ -752,21 +922,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 @@ -775,17 +948,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); } } } @@ -803,25 +977,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" @@ -850,10 +1028,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) }); } } @@ -1402,7 +1580,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 @@ -1410,13 +1588,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(), @@ -1455,6 +1634,7 @@ fn format_context_hints( }; let mut s = format!( "Scope: dm\n\ + Session scope: dm conversation\n\ Channel: {channel_display}\n\ {ctx_hint}" ); @@ -1471,7 +1651,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 { @@ -1481,8 +1664,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); @@ -1495,12 +1684,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); @@ -1777,10 +1971,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 => { @@ -1837,7 +2030,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(), @@ -2027,6 +2227,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(), @@ -2047,6 +2248,7 @@ mod tests { .unwrap(); QueuedEvent { channel_id, + scope: conv(channel_id), event, received_at: Instant::now(), prompt_tag: "test".into(), @@ -2058,7 +2260,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] @@ -2243,6 +2583,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -2273,6 +2614,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(), @@ -2404,6 +2746,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"), @@ -2461,6 +2804,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: steering, prompt_tag: "@mention".into(), @@ -2512,7 +2856,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(); @@ -2553,7 +2897,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" ); @@ -2632,6 +2976,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: e1, @@ -2672,6 +3017,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2695,6 +3041,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(), @@ -2727,6 +3074,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(), @@ -2757,6 +3105,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(), @@ -2784,6 +3133,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2808,6 +3158,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2866,6 +3217,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(), @@ -2919,6 +3271,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2957,6 +3310,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(), @@ -3065,7 +3419,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); @@ -3164,13 +3518,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)); @@ -3187,6 +3541,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(), @@ -3204,6 +3559,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); @@ -3216,7 +3617,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()); } @@ -3322,7 +3723,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" @@ -3337,7 +3738,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(), @@ -3348,15 +3749,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] @@ -3382,7 +3783,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() @@ -3498,6 +3899,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(), @@ -3531,6 +3933,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(), @@ -3557,6 +3960,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(); @@ -3571,6 +4063,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3597,6 +4090,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3729,6 +4223,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), @@ -3753,6 +4248,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), @@ -3780,6 +4276,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(), @@ -3836,6 +4333,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(), @@ -4045,6 +4543,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -4115,6 +4614,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4148,6 +4648,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(), @@ -4198,6 +4699,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(), @@ -4240,6 +4742,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(), @@ -4264,6 +4767,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(), @@ -4287,6 +4791,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(), @@ -4445,25 +4950,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" ); } @@ -4481,17 +4986,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" ); } @@ -4503,11 +5008,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" ); } @@ -4714,6 +5219,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4756,6 +5262,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(), @@ -4792,6 +5299,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(), @@ -4821,6 +5329,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(), @@ -4865,6 +5374,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(), @@ -4901,6 +5411,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4936,6 +5447,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: plain, @@ -4973,6 +5485,7 @@ mod tests { let plain_id = plain.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: threaded, @@ -5004,8 +5517,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(), @@ -5140,7 +5655,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 @@ -5208,9 +5726,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 @@ -5218,7 +5736,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. @@ -5270,20 +5788,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()) @@ -5300,6 +5821,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(), @@ -5329,6 +5851,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(), @@ -5357,6 +5880,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(), @@ -5405,11 +5929,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" @@ -5421,11 +5945,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"); } @@ -5433,17 +5957,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" ); } @@ -5453,17 +5977,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" ); @@ -5482,9 +6006,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")); @@ -5510,10 +6034,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(); @@ -5527,11 +6051,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" ); } @@ -5548,10 +6072,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!( @@ -5559,7 +6083,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" ); @@ -5572,7 +6096,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" ); } @@ -5587,15 +6111,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, @@ -5818,6 +6342,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 6188e57a11..e4e41b4660 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 0000000000..d32207e505 --- /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 0000000000..58f652aa3c --- /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 0000000000..5665520b8d --- /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 b1a9372ea4..88225469aa 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,13 +661,12 @@ 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, &[], ) @@ -699,6 +738,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 +1110,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 +1139,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 42a7de84f7..19a3b1d9d4 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/src/agent.rs b/crates/buzz-agent/src/agent.rs index 0743a75667..5125b28074 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -68,6 +68,12 @@ fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize { /// [`Config::require_reply`](crate::config::Config::require_reply). const MAX_REPLY_NAGS: u32 = 2; +/// Output-token upper bound (inclusive) for the silent-death signature: the +/// observed failure emits 2–12 tokens. Turns with `output_tokens <= 12` +/// and no prior tool call are flagged. Legitimate one-sentence replies land +/// well above this value even in the most terse case. +const SILENT_TURN_TOKEN_THRESHOLD: u64 = 12; + /// Server label on the synthetic reply-guard objection. /// /// Not a real MCP server. It rides the same tool-result path as `_Stop` hook @@ -348,6 +354,11 @@ impl RunCtx<'_> { // // Named for what it proves: a *recognized attempt* to publish, not a // successful publish. See `is_buzz_reply_call`. + // Tracks whether a publish-shaped tool call was seen this turn, updated + // unconditionally (not gated on `require_reply`) so the silent-turn + // diagnostic has a turn-level view regardless of config. A turn that + // ran read-only tools and then died at 3 tokens IS a silent death; + // only a genuine publish should suppress the WARN. let mut buzz_reply_call_seen = false; let mut reply_nags = 0u32; // Per-`run()` reactive context-recovery budget. Per-turn, not @@ -696,12 +707,33 @@ impl RunCtx<'_> { "provider: stop=tool_use but zero tool_calls".into(), )); } + // Capture before response.text is moved into history. + let text_is_empty = response.text.trim().is_empty(); self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: Vec::new(), reasoning_details: response.reasoning_details.clone(), }); let stop = map_stop(response.stop); + // Diagnostic: warn when no publish was seen across the whole + // turn, the final response has no visible text, and the + // token count looks silent. Two independent gates: + // 1. `!buzz_reply_call_seen` — no publish attempt in any + // round (read-only tool calls do NOT suppress: a turn + // that ran tools but never published then died at 3 + // tokens is still a silent death). + // 2. `text_is_empty` — model emitted no visible text + // (a terse reply like "OK" is not silent). + // 3. token count or usage-absent check. + // Fires before the `_Stop` hook so the warning appears in + // the log even if the hook rejects the stop and the loop + // continues. Does not alter control flow. + warn_if_silent_turn( + buzz_reply_call_seen, + text_is_empty, + response.output_tokens, + response.stop, + ); // Only gate genuine end_turn — don't override max_tokens/refusal. if stop == StopReason::EndTurn { if stop_rejections >= self.cfg.stop_max_rejections { @@ -746,7 +778,10 @@ impl RunCtx<'_> { } // Deliberately after truncation: a publish-shaped call that was // discarded never runs, so it must not suppress the reminder. - if self.cfg.require_reply && !buzz_reply_call_seen { + // Updated unconditionally (not gated on `require_reply`) so the + // silent-turn diagnostic has a publish-aware turn-level signal + // regardless of config. + if !buzz_reply_call_seen { buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp)); } self.history.push(HistoryItem::Assistant { @@ -1286,10 +1321,69 @@ fn map_stop(p: ProviderStop) -> StopReason { } } +/// Returns `true` when a reported output-token count is at or below the +/// silent-death threshold. The observed failure signature is 2–12 tokens. +/// +/// Takes a bare `u64` — the caller handles `None` usage separately (a +/// provider that omits token counts is a distinct diagnostic case, not +/// automatically "near-zero"). +/// +/// Extracted as a pure function so it can be tested without standing up an +/// async agent loop. +fn is_silent_turn(output_tokens: u64) -> bool { + output_tokens <= SILENT_TURN_TOKEN_THRESHOLD +} + +/// Emits the silent-turn diagnostic WARN when the turn produced no publish, +/// no visible text, and either near-zero or absent output tokens. +/// +/// `buzz_reply_call_seen` is the publish-aware gate (from +/// `is_buzz_reply_call`), updated unconditionally regardless of +/// `require_reply`. Read-only tool calls do NOT suppress the WARN — a turn +/// that ran tools but never published and then died at 3 tokens is a silent +/// death. +/// +/// Two distinct WARN shapes: +/// - Near-zero token count (`output_tokens <= 12`): canonical silent-death. +/// - Unknown usage (`None`) with no publish and no text: separately +/// diagnostic; does not assert near-zero since the count is unknown. +/// +/// Extracted as a free function so the WARN seam can be exercised by a +/// scoped tracing subscriber without standing up the full async run loop. +fn warn_if_silent_turn( + buzz_reply_call_seen: bool, + text_is_empty: bool, + output_tokens: Option, + stop: ProviderStop, +) { + if buzz_reply_call_seen || !text_is_empty { + return; + } + match output_tokens { + Some(t) if is_silent_turn(t) => { + tracing::warn!( + stop = ?stop, + output_tokens = t, + "agent: turn ended with no publish attempt and near-zero output tokens — possible silent model/gateway early-stop" + ); + } + None => { + tracing::warn!( + stop = ?stop, + "agent: turn ended with no publish attempt and no usage reported — cannot confirm output size" + ); + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tracing_subscriber::layer::SubscriberExt; /// `truncate_history` cannot serve as the context-window fallback: it is /// measured in BYTES (`max_history_bytes`, default 16 MiB, a request-body @@ -1635,4 +1729,146 @@ mod tests { "three identical rounds must remain consistently proven" ); } + + // ── is_silent_turn (pure predicate) ───────────────────────────────────── + + /// Counts WARN events emitted by `warn_if_silent_turn` calls inside `f`. + /// + /// Identifies silent-turn WARNs by target (`buzz_agent::agent`) + WARN + /// level + presence of the `stop` field, which is unique to these two + /// WARNs in this module. Using the target avoids parsing message strings, + /// which are routed through `record_debug` as `Display`-formatted values + /// and are not reliably interceptable via `record_str` across tracing + /// versions. + fn count_silent_turn_warnings(f: impl FnOnce()) -> usize { + struct Capture { + count: Arc, + } + struct Visitor { + saw_stop: bool, + } + impl tracing::field::Visit for Visitor { + fn record_debug(&mut self, field: &tracing::field::Field, _: &dyn std::fmt::Debug) { + if field.name() == "stop" { + self.saw_stop = true; + } + } + } + impl tracing_subscriber::Layer for Capture { + fn on_event( + &self, + event: &tracing::Event<'_>, + _: tracing_subscriber::layer::Context<'_, S>, + ) { + if *event.metadata().level() != tracing::Level::WARN { + return; + } + if event.metadata().target() != "buzz_agent::agent" { + return; + } + let mut v = Visitor { saw_stop: false }; + event.record(&mut v); + if v.saw_stop { + self.count.fetch_add(1, Ordering::SeqCst); + } + } + } + let count = Arc::new(AtomicUsize::new(0)); + let sub = tracing_subscriber::registry().with(Capture { + count: count.clone(), + }); + tracing::subscriber::with_default(sub, f); + count.load(Ordering::SeqCst) + } + + /// Predicate: values within the observed failure range (2–12) fire. + /// Pair (0, 12) catches an always-false mutation and an off-by-one at 12. + #[test] + fn is_silent_turn_fires_at_and_below_threshold() { + assert!( + is_silent_turn(0), + "zero output tokens must be a silent turn" + ); + assert!( + is_silent_turn(SILENT_TURN_TOKEN_THRESHOLD), + "exactly at threshold (12) must be a silent turn — 12 is in the observed range" + ); + } + + /// One above the threshold must NOT fire, catching `<` vs `<=` and + /// always-true mutations. + #[test] + fn is_silent_turn_silent_above_threshold() { + assert!( + !is_silent_turn(SILENT_TURN_TOKEN_THRESHOLD + 1), + "one above threshold (13) must not be a silent turn" + ); + } + + // ── warn_if_silent_turn (WARN seam) ─────────────────────────────────── + + /// The canonical silent-death signature — no publish, no text, ≤12 tokens + /// — must emit exactly one WARN. Deleting the WARN call, weakening the + /// token check, or hardcoding `buzz_reply_call_seen = true` are all caught. + #[test] + fn warn_if_silent_turn_fires_for_canonical_signature() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish seen + true, // no text + Some(4), // 4 tokens — in the 2–12 range + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 1, "canonical silent-death must emit exactly one WARN"); + } + + /// A turn that ends with non-empty assistant text is NOT a silent death + /// even if token count is low — a terse reply like "OK" is legitimate. + /// Deleting the `text_is_empty` gate would cause this to fail. + #[test] + fn warn_if_silent_turn_silent_for_nonempty_text() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish + false, // text IS present + Some(3), // low tokens — would fire without the text gate + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 0, "a turn with non-empty assistant text must not WARN"); + } + + /// A turn that published (buzz_reply_call_seen = true) then ended with a + /// short final completion must not trigger the WARN. This is the normal + /// publish-then-wrap pattern. Deleting the `buzz_reply_call_seen` gate + /// would cause this to fail. + #[test] + fn warn_if_silent_turn_silent_after_publish() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + true, // publish seen + true, // no text in final round + Some(0), // zero tokens — would fire without the publish gate + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 0, "a turn where a publish ran must not WARN"); + } + + /// Unknown usage (None) with no publish and no text emits the distinct + /// "no usage reported" WARN. Mutating the None arm to fall through to + /// `_ => {}` would cause this. + #[test] + fn warn_if_silent_turn_fires_distinct_warn_for_none_usage() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish + true, // no text + None, // provider omitted usage + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 1, "unknown-usage silent turn must emit exactly one WARN"); + } } diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index a78a499bdd..7ebabccbbb 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -93,8 +93,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 +103,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, @@ -444,6 +445,29 @@ fn is_expired(t: &CachedToken) -> bool { now + 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 { let mut h = sha2::Sha256::new(); h.update(cfg.discovery_url.as_bytes()); @@ -455,12 +479,7 @@ 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"))) } @@ -862,7 +881,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(), diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 82f3b086cd..f2cda834fd 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/lib.rs b/crates/buzz-agent/src/lib.rs index b094a0f9fd..3de47c82a4 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 55c85bb0c5..1bac514774 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; @@ -229,6 +230,7 @@ impl Llm { input_tokens = ?response.input_tokens, cached_input_tokens = ?response.cached_input_tokens, output_tokens = ?response.output_tokens, + stop = ?response.stop, "llm: call completed" ); } @@ -2032,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", @@ -2044,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, } } @@ -2069,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 42c9cc4878..a848557ae2 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 940448dd2e..b0e4ebc6e5 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.") }, @@ -638,6 +640,7 @@ mod tests { 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 +731,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.") }, @@ -839,7 +843,7 @@ mod tests { } #[test] - fn corpus_has_exactly_135_executable_vectors() { + fn corpus_has_exactly_139_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 +852,7 @@ mod tests { .filter(|q| matches!(q, Q::Vector { .. })) .count(); assert_eq!( - vectors, 135, + vectors, 139, "corpus executable-vector count changed; update this gate deliberately" ); } @@ -1018,9 +1022,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!( @@ -1053,6 +1060,7 @@ mod tests { "data_workflow_tools.goose.goose-deepseek-v4-flash-0731", "DeepSeek V4 Flash", ), + ("data_workflow_tools.goose.goose-glm-5-3", "GLM-5.3"), ( "data_workflow_tools.goose.goose-glm-5-3-flash", "GLM-5.3 Flash", diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 66251563cb..66ac1d2f8f 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -85,6 +85,15 @@ where let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer) .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)") @@ -690,7 +699,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(), 42); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1232,6 +1241,45 @@ mod tests { operator_audit.contains("_operator_global_tables"), "migration 39 must register relay_operator_audit in _operator_global_tables" ); + + // NIP-FI core identity + base-lifecycle foundation (migration 0041) and + // final-admission foundation (0042). Both widen the single SQL source of + // truth `community_write_fence_excluded_table` so their durable, + // immutable ledger relations are never fence-attached, purged, or + // counted as tenant-scoped drift. schema.sql keeps one consolidated + // definition of that function whose body must match 0042's exactly. + 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!(identity_foundation + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + assert!(identity_foundation.contains("'identity_bindings'")); + + 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")); + assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results")); + + // The consolidated desired-state exclusion function must byte-match + // migration 0042's CREATE OR REPLACE body, or a future schema + // consolidation would silently drop NIP-FI relations from the ledger. + fn extract_excluded_table_array(sql: &str) -> &str { + let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN"; + let start = sql.find(anchor).expect("exclusion function definition"); + let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start; + let array_end = sql[array_start..] + .find("]::TEXT[]") + .expect("exclusion array end") + + array_start; + &sql[array_start..array_end] + } + assert_eq!( + extract_excluded_table_array(authorization_foundation), + extract_excluded_table_array(desired_schema), + "schema.sql exclusion list drifted from migration 0042" + ); } #[test] @@ -2609,4 +2657,2578 @@ mod tests { .await .expect("drop late-table fixtures"); } + + /// NIP-FI intermediate state: migration 0041 (identity + base lifecycle) + /// alone must present a coherent catalog. Its five community-scoped ledger + /// relations are immutable and durable, so they are registered in the + /// write-fence exclusion — never counted as tenant-scoped drift, never + /// fence-attached — and the exact deletion catalog must still validate. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + // The five identity relations exist. + let identity_tables = [ + "authorization_operation_receipts", + "identity_enrollment_policies", + "identity_bindings", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + ]; + for table in identity_tables { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(exists, "migration 0041 must create {table}"); + } + + // Migration B's relations must NOT exist yet. + for table in ["authorization_events", "protected_object_authority"] { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(!exists, "{table} belongs to migration 0042, not 0041"); + } + + // Every identity relation is excluded from the write fence: none may + // appear as tenant-scoped drift or carry the fence trigger. + let scoped_or_fenced: Vec = sqlx::query_scalar( + "WITH scoped AS ( \ + SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + ) \ + SELECT relname FROM scoped \ + WHERE relname = ANY($1) ORDER BY relname", + ) + .bind(&identity_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped identity relations"); + assert!( + scoped_or_fenced.is_empty(), + "identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}" + ); + + // The exact deletion catalog validates: the excluded ledger relations + // do not perturb the scoped-table/fence equality check. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migration 0041"); + + // The immutability contract is enforced, not merely declared. TRUNCATE + // fires the statement-level guard unconditionally, so this proves the + // rejection without constructing a fully valid ledger row. + let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors") + .execute(&pool) + .await + .expect_err("identity_lifecycle_selectors truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } + + /// NIP-FI full state: migrations 0041 + 0042 together must present a + /// coherent 15-relation catalog with zero dangling foreign keys, all + /// relations write-fence excluded, and an intact exact deletion catalog. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + 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", + ]; + + // All fifteen relations exist. + let present: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read NIP-FI table catalog"); + let mut expected: Vec = nip_fi_tables.iter().map(|t| t.to_string()).collect(); + expected.sort(); + assert_eq!( + present, expected, + "all NIP-FI relations must exist after 0042" + ); + + // Zero dangling foreign keys: every FK target is a live relation. + let invalid_fks: i64 = sqlx::query_scalar( + "SELECT count(*)::BIGINT FROM pg_constraint \ + WHERE contype = 'f' AND NOT convalidated", + ) + .fetch_one(&pool) + .await + .expect("read FK validity"); + assert_eq!( + invalid_fks, 0, + "no NIP-FI foreign key may be left unvalidated" + ); + + // None of the fifteen appear as tenant-scoped drift; all are excluded. + let scoped: Vec = sqlx::query_scalar( + "SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + AND c.relname = ANY($1) ORDER BY c.relname", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped NIP-FI relations"); + assert!( + scoped.is_empty(), + "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + ); + + // The exact deletion catalog validates with the full ledger present. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migrations 0041 + 0042"); + + // A migration-B relation is immutable too. TRUNCATE fires the + // statement-level guard unconditionally. + let rejected = sqlx::query("TRUNCATE authorization_admission_results") + .execute(&pool) + .await + .expect_err("authorization_admission_results truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } + + /// NIP-FI monotonic invalidation-floor advancement must actually run + /// through the `BEFORE UPDATE` guard. PL/pgSQL defers record-field + /// resolution to execution, so a guard that references a column absent from + /// its Phase-A table passes every catalog/parity test yet aborts the first + /// real advancement. This test exercises live UPDATEs: legitimate forward + /// moves on `floor_generation` and `binding_version_floor` must commit, and + /// equal/regressive moves must be rejected. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_invalidation_floor_advances_through_guard() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("floor-guard-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Each floor state points at an operation receipt via + // (community_id, operation_id, request_fingerprint). Seed one receipt + // per operation the test advances through. + let operations: [(uuid::Uuid, u8); 4] = [ + (uuid::Uuid::new_v4(), 0x11), + (uuid::Uuid::new_v4(), 0x22), + (uuid::Uuid::new_v4(), 0x33), + (uuid::Uuid::new_v4(), 0x44), + ]; + for (operation_id, fp_byte) in operations { + 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![fp_byte; 32]) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .execute(&pool) + .await + .expect("seed operation receipt"); + } + + // selector_kind 3 requires binding_version_floor, so this row exercises + // both monotonic dimensions the guard still governs. + let selector_fingerprint = vec![0xCC_u8; 32]; + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id, selector_kind, selector_fingerprint, floor_generation, \ + binding_version_floor, operation_id, request_fingerprint, updated_at) \ + VALUES ($1, 3, $2, 1, 1, $3, $4, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .bind(operations[0].0) + .bind(vec![operations[0].1; 32]) + .execute(&pool) + .await + .expect("insert initial invalidation floor"); + + let advance = + |generation: i64, binding_floor: i64, op_index: usize, updated_at: &'static str| { + sqlx::query( + "UPDATE authorization_invalidation_floors \ + SET floor_generation = $1, binding_version_floor = $2, \ + operation_id = $3, request_fingerprint = $4, updated_at = $5::timestamptz \ + WHERE community_id = $6 AND selector_kind = 3 AND selector_fingerprint = $7", + ) + .bind(generation) + .bind(binding_floor) + .bind(operations[op_index].0) + .bind(vec![operations[op_index].1; 32]) + .bind(updated_at) + .bind(community_id) + .bind(selector_fingerprint.clone()) + .execute(&pool) + }; + + // Forward generation advance commits. + advance(2, 1, 1, "2026-01-01T00:01:00Z") + .await + .expect("forward floor_generation advance must pass the guard"); + + // Forward binding_version_floor advance commits (generation unchanged). + advance(2, 2, 2, "2026-01-01T00:02:00Z") + .await + .expect("forward binding_version_floor advance must pass the guard"); + + // Regressive generation is rejected. + let regressive = advance(1, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("regressive floor_generation must be rejected"); + assert!( + regressive.to_string().contains("cannot move backward"), + "expected monotonic rejection, got: {regressive}" + ); + + // Equal floors with only a new operation is a rejected no-op advance. + let no_op = advance(2, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("equal-floor no-op advance must be rejected"); + assert!( + no_op.to_string().contains("cannot move backward"), + "expected no-op rejection, got: {no_op}" + ); + + // The committed state reflects only the two accepted advances. + let (generation, binding_floor): (i64, i64) = sqlx::query_as( + "SELECT floor_generation, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = 3 AND selector_fingerprint = $2", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .fetch_one(&pool) + .await + .expect("read final floor state"); + assert_eq!( + (generation, binding_floor), + (2, 2), + "only the accepted forward advances may persist" + ); + } + + /// NIP-FI identity FK contract: a binding's provenance is determined from + /// operation evidence and is independent of the enrollment policy's mode. + /// The corrected FK references only `(community_id, policy_revision)`; + /// the original composite FK `(community_id, policy_revision, + /// binding_provenance) → (community_id, policy_revision, enrollment_mode)` + /// would have rejected valid admissions such as TOFU policy + + /// attested-key provenance (NIP-FI.md §352, §424). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn identity_binding_provenance_is_independent_of_enrollment_mode() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("provenance-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Enrollment policy: mode 3 (TOFU). + let policy_revision: i64 = 1; + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(policy_revision) + .bind(vec![0xA0_u8; 32]) // policy_digest + .execute(&pool) + .await + .expect("insert TOFU enrollment policy"); + + // Insert a binding with provenance 1 (attested-key) under the TOFU + // policy. The circular deferred FK between identity_bindings and + // identity_lifecycle_history requires both to be committed in one + // transaction; all cross-table FKs in this pair are DEFERRABLE + // INITIALLY DEFERRED. A pinned connection is required so that BEGIN + // and each subsequent statement share the same session/transaction. + let binding_id = uuid::Uuid::new_v4(); + let history_id = uuid::Uuid::new_v4(); + let operation_id = uuid::Uuid::new_v4(); + let request_fingerprint = vec![0xAB_u8; 32]; + + let mut conn = pool.acquire().await.expect("acquire connection"); + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Enrollment history must be inserted BEFORE the operation receipt: + // authorization_operation_receipt_history_guard_v1 fires AFTER INSERT + // on authorization_operation_receipts and checks that lifecycle receipts + // already have exactly one history row. The history → receipt FK is + // DEFERRABLE INITIALLY DEFERRED, so this order is safe. + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(history_id) + .bind(binding_id) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![0xAE_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert lifecycle history"); + + // Operation receipt: kind 1 (enroll), outcome 1 (applied). + // The receipt_history_cardinality trigger fires here and validates the + // history row inserted above. + 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, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![0xAC_u8; 32]) + .bind(vec![0xAD_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert operation receipt"); + + // Binding: provenance 1 (attested-key) under TOFU-mode policy. + // Before the FK fix this INSERT would fail at commit with a FK + // violation because 1 (attested-key) ≠ 3 (TOFU mode). + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-01', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(binding_id) + .bind(vec![0xAF_u8; 32]) // principal_fingerprint + .bind(vec![0xB0_u8; 32]) // event_author_pubkey + .bind(policy_revision) + .bind(vec![0xB1_u8; 32]) // enrollment_evidence_digest + .bind(history_id) + .bind(operation_id) + .bind(&request_fingerprint) + .execute(&mut *conn) + .await + .expect("insert binding"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("attested-key binding under TOFU policy must commit — FK is on (community_id, policy_revision) only"); + + // Confirm the binding persisted with provenance 1, policy mode 3. + let (stored_provenance, stored_mode): (i16, i16) = sqlx::query_as( + "SELECT b.binding_provenance, p.enrollment_mode \ + FROM identity_bindings b \ + JOIN identity_enrollment_policies p \ + ON p.community_id = b.community_id AND p.policy_revision = b.policy_revision \ + WHERE b.community_id = $1 AND b.binding_id = $2", + ) + .bind(community_id) + .bind(binding_id) + .fetch_one(&pool) + .await + .expect("read persisted binding"); + assert_eq!(stored_provenance, 1, "provenance must be attested-key (1)"); + assert_eq!(stored_mode, 3, "enrollment mode must be TOFU (3)"); + assert_ne!( + stored_provenance, stored_mode, + "provenance and mode are independent: they must differ here" + ); + + // --- Negative half: absent policy revision --- + // + // Two-sided mutation sensitivity requires that a FK dropped or neutered + // entirely is also detected. A second otherwise-valid deferred + // transaction uses a nonexistent policy_revision (999) and must fail + // with SQLSTATE 23503 — the narrowed FK + // identity_bindings(community_id, policy_revision) + // → identity_enrollment_policies(community_id, policy_revision) + // rejects the row. This FK is not deferred, so it fires at INSERT + // time; a `COMMIT` is unnecessary and not reached. If the FK were + // absent the INSERT would succeed and this assertion would catch the + // regression. + let absent_binding_id = uuid::Uuid::new_v4(); + let absent_history_id = uuid::Uuid::new_v4(); + let absent_operation_id = uuid::Uuid::new_v4(); + let absent_fp = vec![0xC0_u8; 32]; + let nonexistent_policy_revision: i64 = 999; + + let mut conn2 = pool.acquire().await.expect("acquire second connection"); + + sqlx::query("BEGIN") + .execute(&mut *conn2) + .await + .expect("begin absent-policy transaction"); + + // History first (receipt_history_cardinality guard fires on receipt + // insert and requires the history row to already exist). + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(absent_history_id) + .bind(absent_binding_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .bind(vec![0xC1_u8; 32]) + .execute(&mut *conn2) + .await + .expect("insert absent-policy lifecycle history"); + + 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, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .bind(vec![0xC2_u8; 32]) + .bind(vec![0xC3_u8; 32]) + .execute(&mut *conn2) + .await + .expect("insert absent-policy operation receipt"); + + // The policy FK is not deferred; it fires at INSERT, not COMMIT. + let absent_policy_err = sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-02', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(absent_binding_id) + .bind(vec![0xC4_u8; 32]) // principal_fingerprint (unique, different from first binding) + .bind(vec![0xC5_u8; 32]) // event_author_pubkey (unique, different from first binding) + .bind(nonexistent_policy_revision) + .bind(vec![0xC6_u8; 32]) // enrollment_evidence_digest + .bind(absent_history_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .execute(&mut *conn2) + .await + .expect_err("binding with nonexistent policy_revision must be rejected by the FK"); + assert!( + absent_policy_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected FK violation (23503) for absent policy_revision, got: {absent_policy_err}" + ); + + sqlx::query("ROLLBACK") + .execute(&mut *conn2) + .await + .expect("rollback absent-policy transaction"); + } + + /// NIP-FI policy-revision monotonicity: each new policy revision for a + /// community must strictly exceed the current maximum revision + /// (FI-INV-06 — stable assertion policy). `effective_at` ordering is + /// deliberately not enforced — the downstream constructor stamps every + /// immediately-effective revision with Unix epoch. + /// + /// Mutation sensitivity is two-sided: + /// - neutering the guard lets a replayed or backfilled revision through + /// (the positive half detects insertion into a guarded table); + /// - leaving the guard intact rejects equal/regressive inserts (negative + /// halves detect that each rejection fires). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn identity_enrollment_policy_revision_is_monotonic() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("policy-mono-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // First insertion: no prior rows — should always succeed. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 1, 1, $2, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA1_u8; 32]) + .execute(&pool) + .await + .expect("first policy insertion (revision 1) must succeed"); + + // Forward advance: revision 2. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 2, 1, $2, '2026-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA2_u8; 32]) + .execute(&pool) + .await + .expect("forward advance to revision 2 must succeed"); + + // Seed a gap: skip from 2 to 100, then advance to 101. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 100, 1, $2, '2027-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA3_u8; 32]) + .execute(&pool) + .await + .expect("jump to revision 100 must succeed"); + + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 101, 1, $2, '2027-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA4_u8; 32]) + .execute(&pool) + .await + .expect("advance to revision 101 must succeed"); + + // Negative: unused lower revision 99 — not a PK duplicate (never inserted), + // but the guard must reject it because 99 < MAX(100, 101). This is the + // case a plain PK constraint cannot catch; the named guard must fire. + let backfill_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 99, 1, $2, '2028-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA5_u8; 32]) + .execute(&pool) + .await + .expect_err("unused lower revision 99 must be rejected by the guard"); + assert!( + backfill_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from identity_enrollment_policy_revision_monotonic \ + guard for backfilled revision 99, got: {backfill_err}" + ); + + // Negative: equal revision (101 <= 101). The PK is (community_id, policy_revision) + // so this is a PK duplicate regardless of policy_digest; either 23505 from the PK + // or 23514 from the guard fires first. This case is secondary — the load-bearing + // proof is the unused-99 case above, which is not a PK duplicate. + let replay_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 101, 2, $2, '2028-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA6_u8; 32]) + .execute(&pool) + .await + .expect_err("equal revision must be rejected"); + // PK (23505) or guard (23514) — either proves the insert cannot commit. + assert!( + replay_err + .as_database_error() + .map(|e| { + let code = e.code(); + let c = code.as_deref().unwrap_or(""); + c == "23514" || c == "23505" + }) + .unwrap_or(false), + "expected check_violation (23514) or unique_violation (23505) for replayed revision, \ + got: {replay_err}" + ); + + // Concurrency regression: prove the advisory lock is load-bearing. The + // test uses a controlled two-connection schedule: + // + // 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT + // trigger acquires `pg_advisory_xact_lock(lock_key)` and completes the + // INSERT — tx1 now holds the advisory lock until it commits. + // 2. tx2 opens a transaction on a second backend and issues INSERT for + // revision 103. The trigger fires and blocks inside + // `pg_advisory_xact_lock(lock_key)` waiting for tx1 to release. + // 3. We observe tx2's backend entering a Lock-wait state via + // pg_stat_activity (wait_event_type='Lock', wait_event='advisory'), + // with a bounded timeout — not a sleep. If the advisory-lock call is + // removed from the guard, the trigger returns immediately; tx2 never + // enters the advisory wait, and the poll times out, failing the test. + // This is the mutation-sensitivity guarantee. + // 4. tx1 commits, releasing the advisory lock. tx2 unblocks, its trigger + // reads the fresh MAX=102, and the INSERT succeeds (103 > 102). + // 5. tx2 commits. Both revisions 102 and 103 are present. + use std::time::Instant; + + // tx1: open a transaction and insert revision 102. The INSERT returns after + // the trigger acquires the lock and succeeds; the advisory lock stays held + // until the transaction commits. + let mut conn1 = pool.acquire().await.expect("acquire conn1"); + sqlx::query("BEGIN") + .execute(&mut *conn1) + .await + .expect("begin tx1"); + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xB1_u8; 32]) + .execute(&mut *conn1) + .await + .expect("tx1 INSERT revision 102 must succeed"); + // tx1 holds the advisory lock. Do NOT commit yet. + + // tx2: acquire a separate backend, record its PID, then issue the INSERT. + // The trigger will block on the advisory lock held by tx1. + let pool2 = pool.clone(); + let pool3 = pool.clone(); + let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); + let tx2_task = tokio::spawn(async move { + let mut conn2 = pool2.acquire().await.expect("acquire conn2"); + // Report this backend's PID so the observer can poll pg_stat_activity. + let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *conn2) + .await + .expect("get conn2 backend pid"); + let _ = pid_tx.send(backend_pid); + sqlx::query("BEGIN") + .execute(&mut *conn2) + .await + .expect("begin tx2"); + // This INSERT will block inside the trigger waiting for tx1's advisory lock. + let insert_r = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xB2_u8; 32]) + .execute(&mut *conn2) + .await; + let commit_r = sqlx::query("COMMIT").execute(&mut *conn2).await; + (insert_r, commit_r) + }); + + // Receive tx2's backend PID and wait until it enters an advisory-lock wait. + // Mutation proof: without pg_advisory_xact_lock in the guard, the trigger + // returns immediately; tx2 never parks on an advisory lock; the poll below + // times out and panics, making this test deterministically red. + let tx2_pid = pid_rx.await.expect("tx2 reports its backend pid"); + let deadline = Instant::now() + std::time::Duration::from_secs(10); + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM pg_stat_activity \ + WHERE pid = $1 \ + AND wait_event_type = 'Lock' \ + AND wait_event = 'advisory'\ + )", + ) + .bind(tx2_pid) + .fetch_one(&pool3) + .await + .expect("poll tx2 advisory-lock wait"); + if waiting { + break; + } + assert!( + Instant::now() < deadline, + "tx2 never entered advisory-lock wait — pg_advisory_xact_lock \ + must be present in the guard for the lock to serialize writers" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // tx2 is observably blocked. Commit tx1, releasing the advisory lock. + sqlx::query("COMMIT") + .execute(&mut *conn1) + .await + .expect("tx1 COMMIT must succeed"); + + // tx2 unblocks: the trigger re-runs its SELECT MAX, sees committed 102, + // and INSERT 103 succeeds. Both the INSERT and COMMIT must complete. + let (insert2, commit2) = tx2_task.await.expect("tx2 task completed"); + insert2.expect("tx2 INSERT revision 103 must succeed after tx1 commits"); + commit2.expect("tx2 COMMIT must succeed"); + + // Both revisions 102 and 103 must be present (total: 1, 2, 100, 101, 102, 103). + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("count persisted policy revisions"); + assert_eq!( + count, 6, + "exactly six revisions must persist after the controlled concurrency sequence" + ); + } + + /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 + /// (protected-mutation) receipt must commit with exactly one admission + /// result; an admission result must commit against a kind-11 receipt. + /// + /// Mutation sensitivity is two-sided: + /// - the guard is load-bearing when a kind-11 receipt has no result row + /// (negative A) — without the guard this commits silently; + /// - the guard is load-bearing when a result attaches to a non-kind-11 + /// receipt (negative B) — without the guard this commits silently. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_admission_result_requires_kind_11_receipt_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("adm-result-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity must exist for authorization_events inserts; admission-result + // tests exercise only authorization_operation_receipts and + // authorization_admission_results — no authorization_events rows are + // needed here, but insert capacity anyway to satisfy any trigger + // that reads the policy row defensively. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-11 receipt + admission result in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let fp1 = vec![0xB1_u8; 32]; + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + 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, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB2_u8; 32]) + .bind(vec![0xB3_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert kind-11 receipt"); + + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB4_u8; 32]) // semantic_fingerprint + .bind(vec![0xB5_u8; 32]) // object_key + .execute(&mut *conn) + .await + .expect("insert admission result"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-11 receipt + result must commit"); + drop(conn); + + // --- Negative A: kind-11 receipt without result must be rejected --- + let op2 = uuid::Uuid::new_v4(); + let fp2 = vec![0xC1_u8; 32]; + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + 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, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op2) + .bind(&fp2) + .bind(vec![0xC2_u8; 32]) + .bind(vec![0xC3_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-11 receipt for negative A"); + + let no_result_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-11 receipt without result must be rejected at commit"); + assert!( + no_result_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-11 without result, got: {no_result_err}" + ); + drop(conn_a); + + // --- Negative B: admission result against non-kind-11 receipt --- + // Use operation_kind 12 (invalidation) — no admission result should + // ever attach to it. The guard fires at COMMIT (deferred trigger). + let op3 = uuid::Uuid::new_v4(); + let fp3 = vec![0xD1_u8; 32]; + + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin"); + + 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(op3) + .bind(&fp3) + .bind(vec![0xD2_u8; 32]) + .bind(vec![0xD3_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("insert kind-12 receipt"); + + // The guard is deferred: the INSERT succeeds; the violation surfaces + // at COMMIT when the guard checks that the receipt is kind-11. + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op3) + .bind(&fp3) + .bind(vec![0xD4_u8; 32]) + .bind(vec![0xD5_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("result insert must pass — deferred guard fires at commit, not here"); + + let wrong_kind_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err("result against non-kind-11 receipt must be rejected at commit"); + assert!( + wrong_kind_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}" + ); + drop(conn_b); + + // --- Negative C: mismatched request_fingerprint rejected by composite FK --- + // The admission result table has an immediate composite FK + // (community_id, operation_id, request_fingerprint) + // REFERENCES authorization_operation_receipts(...) + // A result referencing a receipt that exists but with a different + // request_fingerprint must be rejected. This exercises the semantic half + // of Carl finding 2 — cardinality is handled by the deferred trigger; + // coordinate binding is handled by the structural FK. + let op4 = uuid::Uuid::new_v4(); + let fp4_receipt = vec![0xE1_u8; 32]; // fingerprint stored in the receipt + let fp4_wrong = vec![0xE2_u8; 32]; // wrong fingerprint used in the result + + let mut conn_c = pool.acquire().await.expect("acquire connection C"); + sqlx::query("BEGIN") + .execute(&mut *conn_c) + .await + .expect("begin"); + + 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, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op4) + .bind(&fp4_receipt) + .bind(vec![0xE3_u8; 32]) + .bind(vec![0xE4_u8; 32]) + .execute(&mut *conn_c) + .await + .expect("insert kind-11 receipt for negative C"); + + // The admission result FK is immediate (not deferred), so the INSERT + // itself rejects a fingerprint with no matching receipt row. + let wrong_fp_err = sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op4) + .bind(&fp4_wrong) // wrong fingerprint — no matching receipt row + .bind(vec![0xE5_u8; 32]) + .bind(vec![0xE6_u8; 32]) + .execute(&mut *conn_c) + .await + .expect_err("result with mismatched request_fingerprint must be rejected at INSERT"); + // Immediate composite FK fires as foreign_key_violation (23503). + assert!( + wrong_fp_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected foreign_key_violation (23503) for mismatched request_fingerprint, got: {wrong_fp_err}" + ); + sqlx::query("ROLLBACK").execute(&mut *conn_c).await.ok(); + } + + /// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9 + /// (pre-authentication denial) audit event must commit with exactly one + /// denial attempt; a denial attempt must commit with a matching kind-9 + /// audit event. + /// + /// Mutation sensitivity is two-sided: + /// - the event-side guard is load-bearing when a kind-9 event has no + /// attempt row (negative A) — without it this commits silently, making + /// replay reconstruction impossible; + /// - the attempt-side guard is load-bearing for semantic mismatches (negatives + /// B1–B3) — the old deferred FK only checks event existence/kind and would + /// not catch a correlation, reason_code, or attempt_id mismatch. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("denial-attempt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity is required by the authorization_events BEFORE INSERT trigger. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-9 event + denial attempt in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let event1 = uuid::Uuid::new_v4(); + let corr1 = uuid::Uuid::new_v4(); + let attempt1_id = uuid::Uuid::new_v4(); + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Insert denial attempt first (FKs are deferred). + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op1) + .bind(corr1) + .bind(vec![0xE1_u8; 32]) // semantic_fingerprint + .bind(attempt1_id) + .bind(event1) + .execute(&mut *conn) + .await + .expect("insert denial attempt before event"); + + // Insert the kind-9 event (actor_kind 4, no request_fingerprint). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event1) + .bind(op1) + .bind(corr1) + .bind(attempt1_id) + .bind(vec![0xE1_u8; 32]) // semantic_fingerprint matches denial attempt + .bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes) + .bind(vec![0xE3_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert kind-9 event"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-9 event + denial attempt must commit"); + drop(conn); + + // --- Negative A: kind-9 event alone must be rejected at commit --- + let op2 = uuid::Uuid::new_v4(); + let event2 = uuid::Uuid::new_v4(); + let corr2 = uuid::Uuid::new_v4(); + let attempt2_id = uuid::Uuid::new_v4(); + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event2) + .bind(op2) + .bind(corr2) + .bind(attempt2_id) + .bind(vec![0xF0_u8; 32]) // semantic_fingerprint (non-zero) + .bind(vec![0xF1_u8; 64]) + .bind(vec![0xF2_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-9 event without attempt"); + + let no_attempt_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-9 event without denial attempt must be rejected at commit"); + assert!( + no_attempt_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-9 event without attempt, got: {no_attempt_err}" + ); + drop(conn_a); + + // --- Negatives B1-B3: semantic coordinate mismatches, each attributed to + // the named guard (23514), not the old deferred FK (23503). Each case + // inserts a valid event then a denial attempt that matches everywhere + // except one coordinate; the guard must fire for that mismatch. + + // B1: correlation_id mismatch — attempt carries a different correlation + // than the event it references. + let op_b1 = uuid::Uuid::new_v4(); + let event_b1 = uuid::Uuid::new_v4(); + let corr_b1_event = uuid::Uuid::new_v4(); + let corr_b1_wrong = uuid::Uuid::new_v4(); // different from corr_b1_event + let attempt_b1 = uuid::Uuid::new_v4(); + + let mut conn_b1 = pool.acquire().await.expect("acquire connection B1"); + sqlx::query("BEGIN") + .execute(&mut *conn_b1) + .await + .expect("begin B1"); + + // Insert the event first (deferred FK allows this ordering). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b1) + .bind(op_b1) + .bind(corr_b1_event) + .bind(attempt_b1) + .bind(vec![0xB3_u8; 32]) // semantic_fingerprint matches denial attempt + .bind(vec![0xB1_u8; 64]) + .bind(vec![0xB2_u8; 32]) + .execute(&mut *conn_b1) + .await + .expect("insert kind-9 event for B1"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b1) + .bind(corr_b1_wrong) // wrong correlation_id + .bind(vec![0xB3_u8; 32]) + .bind(attempt_b1) + .bind(event_b1) + .execute(&mut *conn_b1) + .await + .expect("insert denial attempt with wrong correlation_id (guard deferred)"); + + let corr_err = sqlx::query("COMMIT") + .execute(&mut *conn_b1) + .await + .expect_err("mismatched correlation_id must be rejected at commit"); + assert!( + corr_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ + for correlation_id mismatch, got: {corr_err}" + ); + drop(conn_b1); + + // B2: reason_code mismatch — attempt carries denial_reason=1 (MissingCredential, + // requires reason_code=2 per canonical mapping), but event carries reason_code=1. + // The attempt INSERT passes (denial_reason=1↔reason_code=2 is a valid mapping pair), + // then the deferred guard fires at commit because event reason_code=1 ≠ attempt + // reason_code=2. + let op_b2 = uuid::Uuid::new_v4(); + let event_b2 = uuid::Uuid::new_v4(); + let corr_b2 = uuid::Uuid::new_v4(); + let attempt_b2 = uuid::Uuid::new_v4(); + + let mut conn_b2 = pool.acquire().await.expect("acquire connection B2"); + sqlx::query("BEGIN") + .execute(&mut *conn_b2) + .await + .expect("begin B2"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b2) + .bind(op_b2) + .bind(corr_b2) + .bind(attempt_b2) + .bind(vec![0xC3_u8; 32]) // semantic_fingerprint matches denial attempt + .bind(vec![0xC1_u8; 64]) + .bind(vec![0xC2_u8; 32]) + .execute(&mut *conn_b2) + .await + .expect("insert kind-9 event for B2 (reason_code=1)"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + // reason_code = 2 but event has reason_code = 1 + ) + .bind(community_id) + .bind(op_b2) + .bind(corr_b2) + .bind(vec![0xC3_u8; 32]) + .bind(attempt_b2) + .bind(event_b2) + .execute(&mut *conn_b2) + .await + .expect( + "insert denial attempt with wrong reason_code (deferred guard will fire at commit)", + ); + + let reason_err = sqlx::query("COMMIT") + .execute(&mut *conn_b2) + .await + .expect_err("mismatched reason_code must be rejected at commit"); + assert!( + reason_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ + for reason_code mismatch, got: {reason_err}" + ); + drop(conn_b2); + + // B3: attempt_id mismatch — the denial attempt's attempt_id FK references + // a different event (attempt_b3_wrong) than the one being paired (event_b3). + // The attempt_id FK on the denial attempt table binds + // (community_id, operation_id, audit_event_kind, attempt_id) + // -> authorization_events(community_id, operation_id, event_kind, attempt_id) + // so using a different attempt_id that doesn't exist for this operation + // will be caught as a FK violation (23503) at commit. + let op_b3 = uuid::Uuid::new_v4(); + let event_b3 = uuid::Uuid::new_v4(); + let corr_b3 = uuid::Uuid::new_v4(); + let attempt_b3_correct = uuid::Uuid::new_v4(); + let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation + + let mut conn_b3 = pool.acquire().await.expect("acquire connection B3"); + sqlx::query("BEGIN") + .execute(&mut *conn_b3) + .await + .expect("begin B3"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b3) + .bind(op_b3) + .bind(corr_b3) + .bind(attempt_b3_correct) + .bind(vec![0xD3_u8; 32]) // semantic_fingerprint (matches denial attempt) + .bind(vec![0xD1_u8; 64]) + .bind(vec![0xD2_u8; 32]) + .execute(&mut *conn_b3) + .await + .expect("insert kind-9 event for B3"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b3) + .bind(corr_b3) + .bind(vec![0xD3_u8; 32]) + .bind(attempt_b3_wrong) // wrong attempt_id — no matching UNIQUE row on events + .bind(event_b3) + .execute(&mut *conn_b3) + .await + .expect("insert denial attempt with wrong attempt_id (FK is deferred)"); + + let attempt_err = sqlx::query("COMMIT") + .execute(&mut *conn_b3) + .await + .expect_err("mismatched attempt_id must be rejected at commit"); + // The attempt_id FK is deferred and fires as foreign_key_violation (23503). + assert!( + attempt_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected foreign_key_violation (23503) for attempt_id mismatch \ + (deferred FK on denial attempt), got: {attempt_err}" + ); + + // B4: denial_reason ↔ reason_code mapping violation — the denial attempt + // row carries denial_reason=2 (InvalidCredential) but reason_code=2 + // (Missing). The canonical mapping requires InvalidCredential(2)↔Invalid(3); + // reason_code=2 is only valid for MissingCredential(denial_reason=1). + // The immediate CHECK constraint authorization_denial_reason_reason_code_binding + // fires at INSERT, not commit. Mutation-sensitive: removing the CHECK lets + // this INSERT succeed (the guard does not compare denial_reason; only the + // paired event's reason_code is checked at commit). + let op_b4 = uuid::Uuid::new_v4(); + let event_b4 = uuid::Uuid::new_v4(); + let corr_b4 = uuid::Uuid::new_v4(); + let attempt_b4 = uuid::Uuid::new_v4(); + + let mut conn_b4 = pool.acquire().await.expect("acquire connection B4"); + sqlx::query("BEGIN") + .execute(&mut *conn_b4) + .await + .expect("begin B4"); + + // Insert the matching kind-9 event first. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b4) + .bind(op_b4) + .bind(corr_b4) + .bind(attempt_b4) + .bind(vec![0xE4_u8; 32]) // semantic_fingerprint + .bind(vec![0xE5_u8; 64]) + .bind(vec![0xE6_u8; 32]) + .execute(&mut *conn_b4) + .await + .expect("insert kind-9 event for B4"); + + // Insert denial attempt with denial_reason=2 (InvalidCredential) but + // reason_code=2 (Missing) — violates the canonical mapping (requires reason_code=3). + let denial_reason_err = sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, $6, 9)", + // denial_reason=2 (InvalidCredential) requires reason_code=3; reason_code=2 is wrong + ) + .bind(community_id) + .bind(op_b4) + .bind(corr_b4) + .bind(vec![0xE4_u8; 32]) + .bind(attempt_b4) + .bind(event_b4) + .execute(&mut *conn_b4) + .await + .expect_err("denial_reason/reason_code mapping violation must be rejected at INSERT"); + + assert!( + denial_reason_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from \ + authorization_denial_reason_reason_code_binding for denial_reason mismatch, \ + got: {denial_reason_err}" + ); + + // B5: semantic_fingerprint mismatch — the event carries semantic_fingerprint + // 0xB5…B5 while the denial attempt carries 0xB6…B6. correlation_id, reason_code, + // and attempt_id all match; only the fingerprint differs. The deferred guard + // authorization_denial_attempt_guard_v1 fires at COMMIT on the denial-attempt + // side, compares found_semantic_fingerprint (from the event) with + // NEW.semantic_fingerprint (from the attempt), and raises 23514 with named + // constraint authorization_denial_attempt_semantic_binding. + // Mutation-sensitive: removing the semantic_fingerprint comparison block from + // the guard function lets this transaction commit. + let op_b5 = uuid::Uuid::new_v4(); + let event_b5 = uuid::Uuid::new_v4(); + let corr_b5 = uuid::Uuid::new_v4(); + let attempt_b5 = uuid::Uuid::new_v4(); + let fp_event_b5 = vec![0xB5_u8; 32]; // event semantic_fingerprint + let fp_attempt_b5 = vec![0xB6_u8; 32]; // mismatched attempt semantic_fingerprint + + let mut conn_b5 = pool.acquire().await.expect("acquire connection B5"); + sqlx::query("BEGIN") + .execute(&mut *conn_b5) + .await + .expect("begin B5"); + + // Insert the kind-9 event with fingerprint 0xB5…B5. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b5) + .bind(op_b5) + .bind(corr_b5) + .bind(attempt_b5) + .bind(fp_event_b5) + .bind(vec![0xB7_u8; 64]) // canonical_envelope + .bind(vec![0xB8_u8; 32]) // envelope_digest + .execute(&mut *conn_b5) + .await + .expect("insert kind-9 event for B5"); + + // Insert denial attempt with the WRONG semantic_fingerprint (0xB6…B6). + // correlation_id, reason_code=2, denial_reason=1 (MissingCredential↔Missing), + // and attempt_id all match the event — only semantic_fingerprint differs. + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b5) + .bind(corr_b5) + .bind(fp_attempt_b5) // 0xB6…B6 ≠ event's 0xB5…B5 + .bind(attempt_b5) + .bind(event_b5) + .execute(&mut *conn_b5) + .await + .expect( + "insert denial attempt with mismatched fingerprint (deferred guard fires at commit)", + ); + + let fp_mismatch_err = sqlx::query("COMMIT") + .execute(&mut *conn_b5) + .await + .expect_err("commit with mismatched semantic_fingerprint must be rejected"); + + assert!( + fp_mismatch_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from \ + authorization_denial_attempt_semantic_binding for semantic_fingerprint mismatch, \ + got: {fp_mismatch_err}" + ); + } + + /// NIP-FI authenticated kind-9 OperatorDenied denial: an authenticated + /// kind-9 event (actor_kind 1–3, non-null request_fingerprint) must commit + /// without an authorization_authentication_denial_attempts row and must + /// reject any attempt to attach one. + /// + /// Mutation sensitivity: + /// - Removing the `actor_kind <> 4` guard from the event-side trigger makes + /// positive A red: the COMMIT fails because the guard now requires a + /// denial-attempt row for the authenticated event and none is present. + /// - Removing the `actor_kind <> 4` shape guard from the attempt-side + /// trigger makes negative B red: the COMMIT is rejected by the pre-existing + /// `authorization_denial_attempt_semantic_binding` guard instead (non-null + /// attempt `semantic_fingerprint` vs. null on the authenticated event), so + /// `assert_eq!` on the constraint name fails. The exact constraint name + /// assertion is therefore the load-bearing proof that the new shape guard — + /// not the pre-existing semantic-binding check — is what fires. + /// + /// The unresolved pre-auth positive path (actor_kind 4) is exercised in + /// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and + /// is unchanged by this fix. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authenticated_kind_9_denial_commits_without_denial_attempt() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("auth-denial-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // Seed an operation receipt for the authenticated denial. Use + // operation_kind = 12 (invalidation) with outcome_code = 2 (denied): + // this satisfies the receipt CHECK constraints without triggering the + // lifecycle history guard (expected_count = 0 for non-lifecycle kinds) + // and without requiring a lifecycle event (expected_event_kind = NULL). + // The authorization_events FK on (community_id, operation_id, + // request_fingerprint) requires a receipt row. + let op_auth = uuid::Uuid::new_v4(); + let fp_auth = vec![0xA1_u8; 32]; + + 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, 2, $5)", + // operation_kind 12 (invalidation), outcome_code 2 (denied) + ) + .bind(community_id) + .bind(op_auth) + .bind(&fp_auth) + .bind(vec![0xA2_u8; 32]) // actor_fingerprint + .bind(vec![0xA3_u8; 32]) // result_digest + .execute(&pool) + .await + .expect("seed authenticated denial receipt"); + + let event_auth = uuid::Uuid::new_v4(); + let corr_auth = uuid::Uuid::new_v4(); + let attempt_auth = uuid::Uuid::new_v4(); + + // --- Positive A: authenticated kind-9 denial (actor_kind = 1) commits + // without any denial-attempt row. The semantic_fingerprint must be NULL + // per the corrected shape CHECK. The deferred cardinality guard must + // skip this event because actor_kind ≠ 4. + let mut conn = pool.acquire().await.expect("acquire connection"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + ) + .bind(community_id) + .bind(event_auth) + .bind(vec![0xA4_u8; 32]) // actor_fingerprint (required for actor_kind 1) + .bind(op_auth) + .bind(&fp_auth) // non-null request_fingerprint (authenticated shape) + .bind(corr_auth) + .bind(attempt_auth) + // semantic_fingerprint = NULL: authenticated kind-9 must not carry one + .bind(vec![0xA5_u8; 64]) // canonical_envelope + .bind(vec![0xA6_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert authenticated kind-9 event"); + + sqlx::query("COMMIT").execute(&mut *conn).await.expect( + "authenticated kind-9 denial must commit without a denial-attempt row \ + — the event-side cardinality guard must skip actor_kind 1", + ); + drop(conn); + + // Confirm no denial attempt was needed: the table must have zero rows + // for this event. + let attempt_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_authentication_denial_attempts \ + WHERE community_id = $1 AND audit_event_id = $2", + ) + .bind(community_id) + .bind(event_auth) + .fetch_one(&pool) + .await + .expect("count denial attempts for authenticated event"); + assert_eq!( + attempt_count, 0, + "no denial-attempt row should exist for an authenticated kind-9 event" + ); + + // --- Negative B: a denial attempt cannot bind to the authenticated kind-9 + // event. The attempt-side shape guard must reject this at commit because + // the referenced event has actor_kind = 1 (not 4). The rejection must + // name the exact shape constraint (authorization_denial_attempt_event_kind) + // rather than merely returning 23514, proving the new actor/request-fingerprint + // guard fires — not the pre-existing semantic_fingerprint equality check + // (which would fire as authorization_denial_attempt_semantic_binding if + // the shape guard were absent, because the attempt carries a non-null + // semantic_fingerprint while the authenticated event has null). + // + // Reuse attempt_auth from the committed event so the deferred attempt_id + // FK resolves (wrong attempt_id would activate that FK first and make the + // negative non-isolated to the new guard). + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin B"); + + // Insert the denial attempt referencing the authenticated event. + // The attempt table FKs are deferred, so this INSERT succeeds; + // the shape guard fires at COMMIT. + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_auth) + .bind(corr_auth) + .bind(vec![0xA7_u8; 32]) // semantic_fingerprint on the attempt (non-null) + .bind(attempt_auth) // reuse the event's attempt_id — FK isolation + .bind(event_auth) // references the authenticated event (actor_kind = 1) + .execute(&mut *conn_b) + .await + .expect("attempt INSERT must pass — shape guard is deferred"); + + let cross_shape_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err( + "denial attempt binding to authenticated kind-9 event must be rejected at commit", + ); + // The exact constraint name must be authorization_denial_attempt_event_kind — + // the new actor/request_fingerprint shape guard. If the shape guard were + // removed, the pre-existing semantic_fingerprint equality check would fire + // instead, named authorization_denial_attempt_semantic_binding. Requiring + // the exact name makes the mutation reliably red. + assert_eq!( + cross_shape_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denial_attempt_event_kind"), + "rejection must be attributed to authorization_denial_attempt_event_kind \ + shape guard (not an incidental FK or semantic-binding check), \ + got: {cross_shape_err}" + ); + } + + /// NIP-FI denied lifecycle receipt: a denied core lifecycle receipt + /// (outcome_code = 2) must commit without a paired audit event. Requiring + /// one would falsely record that the lifecycle transition occurred. + /// + /// The denied branch forbids any event from the complete core + /// success-transition class (kinds 1, 2, 3, 6). This test uses the mapped + /// kind (kind 1 for enroll). Cross-kind rejection — a wrong success-transition + /// kind on a denied receipt — is exercised by + /// `denied_lifecycle_receipt_wrong_kind_receipt_side` (receipt-side trigger) + /// and `denied_lifecycle_receipt_wrong_kind_event_side` (event-side trigger). + /// + /// Mutation sensitivity: + /// - Removing the `outcome_code IN (1, 3)` branch entirely (or replacing it with a + /// blanket early-return) makes the positive case red — the denied enroll receipt + /// cannot commit alone because the guard then demands a paired enroll audit event + /// (expected_event_kind = 1) that is absent. + /// - Removing the `ELSIF outcome_code = 2` zero-event branch makes the + /// receipt-then-event negative below green (COMMIT succeeds when it must not), + /// failing `expect_err`. The event-side isolation in + /// `denied_lifecycle_receipt_event_side_trigger_isolated` independently confirms + /// the same branch using only the `authorization_event_receipt_cardinality` + /// trigger direction. + /// Applied/no-op lifecycle cardinality is exercised by + /// `applied_lifecycle_receipt_requires_exactly_one_event`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_commits_without_audit_event() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "denied-lifecycle-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert community"); + + // Denied enroll receipt (operation_kind = 1, outcome_code = 2) must commit + // without any paired authorization_events row. The guard must skip it + // because outcome_code = 2 is not in (1, 3). + // + // The receipt history guard (migration 0041) uses `outcome_code IN (1, 3)` + // for lifecycle receipts, so a denied enroll receipt (outcome_code = 2) + // expects zero lifecycle history rows — no history setup is needed. + let op_denied = uuid::Uuid::new_v4(); + let fp_denied = vec![0xB1_u8; 32]; + + 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, 1, $4, 2, $5)", + // operation_kind 1 (enroll), outcome_code 2 (denied) + ) + .bind(community_id) + .bind(op_denied) + .bind(&fp_denied) + .bind(vec![0xB2_u8; 32]) + .bind(vec![0xB3_u8; 32]) + .execute(&pool) + .await + .expect("denied enroll receipt must commit without a paired audit event"); + + // No audit event for this operation; confirm the table is empty for it. + let event_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_events \ + WHERE community_id = $1 AND operation_id = $2", + ) + .bind(community_id) + .bind(op_denied) + .fetch_one(&pool) + .await + .expect("count events for denied receipt"); + assert_eq!( + event_count, 0, + "no audit event should be required or present for a denied lifecycle receipt" + ); + + // --- Negative: denied enroll receipt paired with its mapped success- + // transition event (event_kind = 1, enrolled) must be rejected at COMMIT. + // The receipt-side deferred trigger fires here (receipt was inserted in + // this same transaction). The event-side trigger direction is isolated in + // `denied_lifecycle_receipt_event_side_trigger_isolated`. + // + // Seed event capacity; the authorization_events BEFORE INSERT trigger + // requires a capacity row. No lifecycle history is needed: denied receipts + // (outcome_code = 2) expect zero history rows per the history guard. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let op_neg = uuid::Uuid::new_v4(); + let fp_neg = vec![0xD1_u8; 32]; + let event_neg = uuid::Uuid::new_v4(); + let corr_neg = uuid::Uuid::new_v4(); + let attempt_neg = uuid::Uuid::new_v4(); + + let mut conn_neg = pool.acquire().await.expect("acquire connection neg"); + sqlx::query("BEGIN") + .execute(&mut *conn_neg) + .await + .expect("begin neg"); + + // Denied enroll receipt — no history row needed (outcome_code = 2). + 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, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_neg) + .bind(&fp_neg) + .bind(vec![0xD2_u8; 32]) + .bind(vec![0xD3_u8; 32]) + .execute(&mut *conn_neg) + .await + .expect("insert denied receipt — event guard is deferred"); + + // Insert the mapped success-transition event (event_kind = 1, enrolled). + // actor_kind = 1 requires a non-null actor_fingerprint and a matching + // receipt FK (satisfied by the denied receipt above, which shares the + // same (community_id, operation_id, request_fingerprint)). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + // event_kind 1 (enrolled) — the mapped success transition for enroll + ) + .bind(community_id) + .bind(event_neg) + .bind(vec![0xD4_u8; 32]) // actor_fingerprint + .bind(op_neg) + .bind(&fp_neg) + .bind(corr_neg) + .bind(attempt_neg) + .bind(vec![0xD5_u8; 64]) // canonical_envelope + .bind(vec![0xD6_u8; 32]) // envelope_digest + .execute(&mut *conn_neg) + .await + .expect("event INSERT must pass — deferred guard fires at COMMIT"); + + let contradiction_err = sqlx::query("COMMIT") + .execute(&mut *conn_neg) + .await + .expect_err( + "denied receipt + mapped success event must be rejected at COMMIT \ + — contradictory durable facts must not be permitted", + ); + assert_eq!( + contradiction_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "expected authorization_denied_lifecycle_receipt_no_success_event constraint \ + rejection for denied receipt + success event, got: {contradiction_err}" + ); + } + + /// NIP-FI event-side trigger isolation: when a denied enroll receipt is already + /// committed (auto-commit via pool), a new independent transaction that inserts + /// only the mapped success-transition event must be rejected at COMMIT by + /// `authorization_event_receipt_cardinality` (the event-side deferred trigger). + /// + /// This isolates the `authorization_event_receipt_cardinality` trigger path. + /// In `denied_lifecycle_receipt_commits_without_audit_event`'s receipt-then-event + /// negative, the receipt-side trigger (`authorization_operation_receipt_event_cardinality`) + /// also fires. Here the committed receipt produces no deferred trigger, so rejection + /// can only come from the event-side trigger. Uses the mapped kind (kind 1 for + /// enroll). Wrong-kind event-side isolation is in + /// `denied_lifecycle_receipt_wrong_kind_event_side`. + /// + /// Mutation sensitivity: disabling the + /// `authorization_event_receipt_cardinality` trigger (DROP or ALTER TABLE + /// DISABLE TRIGGER) makes this negative green — the COMMIT succeeds when it + /// must not, so `expect_err` panics. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_event_side_trigger_isolated() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("evt-side-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Seed event capacity before any event insert. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // Commit a denied enroll receipt in auto-commit mode (no explicit BEGIN). + // This receipt produces no deferred trigger — the receipt-side deferred + // trigger only fires within the transaction that inserts the receipt row. + let op_id = uuid::Uuid::new_v4(); + let fp = vec![0xE1_u8; 32]; + 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, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xE2_u8; 32]) + .bind(vec![0xE3_u8; 32]) + .execute(&pool) + .await + .expect("denied receipt must commit alone in auto-commit mode"); + + // Now open a NEW transaction and insert only the mapped success-transition + // event (event_kind = 1, enrolled). The receipt is already committed and + // its deferred trigger is no longer active. Rejection at COMMIT must come + // from authorization_event_receipt_cardinality (the event-side trigger). + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for event-side test"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin event-side transaction"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xE4_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xE5_u8; 64]) // canonical_envelope + .bind(vec![0xE6_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); + + let event_side_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( + "mapping a success-transition event to a committed denied receipt \ + must be rejected at COMMIT by the event-side trigger", + ); + assert_eq!( + event_side_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event, \ + got: {event_side_err}" + ); + } + + /// NIP-FI applied lifecycle receipt: an applied core lifecycle enroll receipt + /// (outcome_code = 1) requires exactly one mapped success-transition event + /// (event_kind = 1, enrolled). This exercises the `outcome_code IN (1, 3)` + /// branch of `authorization_operation_receipt_event_guard_v1` at migration 42. + /// + /// Mutation sensitivity: + /// - Removing/bypassing the applied/no-op branch (replacing it with a blanket + /// RETURN NULL) makes the positive transaction commit without an event, leaving + /// the contract silently unenforced. The negative below requires the cardinality + /// constraint to fire when the event is absent. + /// - Removing the negative assertion: the absent-event case would commit when it + /// must not. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn applied_lifecycle_receipt_requires_exactly_one_event() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "applied-lifecycle-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert community"); + + // Enrollment policy (TOFU, mode 3). + let policy_revision: i64 = 1; + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(policy_revision) + .bind(vec![0xF0_u8; 32]) + .execute(&pool) + .await + .expect("insert enrollment policy"); + + // Event capacity — required by authorization_event_capacity_before_insert_v1. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // --- Positive: applied enroll commits with exactly one mapped event --- + // + // All cross-table FKs between identity_lifecycle_history, identity_bindings, + // authorization_operation_receipts, and authorization_events are + // DEFERRABLE INITIALLY DEFERRED — insert order within the transaction is + // flexible, but a pinned connection is required for BEGIN/COMMIT to share + // the same session. The receipt_history_cardinality trigger (migration 0041) + // fires at COMMIT and requires exactly one history row for applied enroll. + let op_id = uuid::Uuid::new_v4(); + let binding_id = uuid::Uuid::new_v4(); + let history_id = uuid::Uuid::new_v4(); + let fp = vec![0xF1_u8; 32]; + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for positive case"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin positive transaction"); + + // History first: the receipt_history_cardinality AFTER INSERT trigger + // on authorization_operation_receipts is DEFERRED and checks at COMMIT + // time, but inserting history before receipt is idiomatic. + // successor_binding_version = 1 because binding_version is an identity + // sequence starting at 1 per community; this is the first binding. + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(history_id) + .bind(binding_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xF2_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert lifecycle history"); + + // Applied enroll receipt (operation_kind = 1, outcome_code = 1). + 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, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xF3_u8; 32]) + .bind(vec![0xF4_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert applied enroll receipt"); + + // Binding — birth_history_id FK is deferred; binding_version is generated. + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-applied', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(binding_id) + .bind(vec![0xF5_u8; 32]) // principal_fingerprint + .bind(vec![0xF6_u8; 32]) // event_author_pubkey + .bind(policy_revision) + .bind(vec![0xF7_u8; 32]) // enrollment_evidence_digest + .bind(history_id) + .bind(op_id) + .bind(&fp) + .execute(&mut *conn) + .await + .expect("insert identity binding"); + + // Mapped success-transition event (event_kind = 1, enrolled). + // actor_kind = 1 requires non-null actor_fingerprint and matching receipt FK. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xF8_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xF9_u8; 64]) // canonical_envelope + .bind(vec![0xFA_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert mapped success-transition event"); + + sqlx::query("COMMIT").execute(&mut *conn).await.expect( + "applied enroll receipt + exactly one mapped event must commit — \ + authorization_operation_receipt_event_guard_v1 applied/no-op branch", + ); + + // Confirm exactly one event committed for this operation. + let event_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_events \ + WHERE community_id = $1 AND operation_id = $2", + ) + .bind(community_id) + .bind(op_id) + .fetch_one(&pool) + .await + .expect("count events for applied receipt"); + assert_eq!( + event_count, 1, + "exactly one audit event must be present for an applied enroll receipt" + ); + + // --- Negative: applied enroll receipt without a mapped event must reject --- + // + // A second applied enroll transaction that commits receipt + history + binding + // but no event must be rejected with authorization_operation_receipt_event_cardinality. + let op_neg = uuid::Uuid::new_v4(); + let binding_neg = uuid::Uuid::new_v4(); + let history_neg = uuid::Uuid::new_v4(); + let fp_neg = vec![0xFB_u8; 32]; + + let mut conn_neg = pool + .acquire() + .await + .expect("acquire connection for negative case"); + sqlx::query("BEGIN") + .execute(&mut *conn_neg) + .await + .expect("begin negative transaction"); + + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", + // successor_binding_version = 2: second binding in this community + ) + .bind(community_id) + .bind(history_neg) + .bind(binding_neg) + .bind(op_neg) + .bind(&fp_neg) + .bind(vec![0xFC_u8; 32]) + .execute(&mut *conn_neg) + .await + .expect("insert negative lifecycle history"); + + 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, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(op_neg) + .bind(&fp_neg) + .bind(vec![0xFD_u8; 32]) + .bind(vec![0xFE_u8; 32]) + .execute(&mut *conn_neg) + .await + .expect("insert negative applied receipt"); + + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-applied-neg', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(binding_neg) + .bind(vec![0xE7_u8; 32]) // principal_fingerprint (distinct from positive) + .bind(vec![0xE8_u8; 32]) // event_author_pubkey (distinct from positive) + .bind(policy_revision) + .bind(vec![0xE9_u8; 32]) + .bind(history_neg) + .bind(op_neg) + .bind(&fp_neg) + .execute(&mut *conn_neg) + .await + .expect("insert negative binding — no event inserted"); + + // Commit without the mapped event — guard must reject. + let absent_event_err = sqlx::query("COMMIT") + .execute(&mut *conn_neg) + .await + .expect_err( + "applied enroll receipt without a mapped success-transition event \ + must be rejected at COMMIT", + ); + assert_eq!( + absent_event_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_operation_receipt_event_cardinality"), + "expected authorization_operation_receipt_event_cardinality rejection \ + for applied receipt without event, got: {absent_event_err}" + ); + } + + /// NIP-FI cross-kind denied lifecycle: a wrong success-transition kind paired + /// with a denied lifecycle receipt must be rejected through the receipt-side + /// deferred trigger. Uses a denied enroll receipt (operation_kind = 1, mapped + /// kind = 1) with a kind-6 (retired) event — a different success-transition + /// kind that is equally forbidden by the class-based guard (kinds 1, 2, 3, 6). + /// + /// Both the receipt and the wrong-kind event are inserted in the same + /// transaction, so the receipt-side deferred trigger + /// (`authorization_operation_receipt_event_cardinality`) fires at COMMIT. + /// + /// Mutation sensitivity: narrowing the denied filter back to + /// `event_kind = expected_event_kind` (the mapped kind, 1) removes kind 6 + /// from the forbidden set, causing this negative to turn green — COMMIT + /// succeeds when it must not, failing `expect_err`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_wrong_kind_receipt_side() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("wrong-kind-rcpt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let op_id = uuid::Uuid::new_v4(); + let fp = vec![0xA0_u8; 32]; + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for wrong-kind receipt-side test"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Denied enroll receipt (operation_kind = 1, outcome_code = 2). + // No history row needed: outcome_code = 2 expects zero history rows. + 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, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xA1_u8; 32]) + .bind(vec![0xA2_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert denied enroll receipt — deferred guard"); + + // Wrong success-transition kind: event_kind = 6 (retired), not the mapped + // kind 1 (enrolled). Both are in the forbidden class (1, 2, 3, 6). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + // event_kind 6 (retired) — wrong kind for a denied enroll receipt + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xA3_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xA4_u8; 64]) // canonical_envelope + .bind(vec![0xA5_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("event INSERT must pass — deferred guard fires at COMMIT"); + + let wrong_kind_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( + "denied receipt + wrong success-transition kind (6) must be rejected at COMMIT \ + — class-based guard forbids all of kinds 1, 2, 3, 6", + ); + assert_eq!( + wrong_kind_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "expected authorization_denied_lifecycle_receipt_no_success_event for \ + denied receipt + wrong kind (6), got: {wrong_kind_err}" + ); + } + + /// NIP-FI cross-kind denied lifecycle event-side: after a denied enroll + /// receipt is committed alone (auto-commit), a new transaction that inserts + /// only a wrong success-transition kind (kind 6, retired) must be rejected at + /// COMMIT by `authorization_event_receipt_cardinality` (event-side trigger). + /// + /// This isolates the event-side trigger path for the cross-kind case. + /// The committed receipt produces no active deferred trigger, so rejection + /// can only come from the event-side trigger. + /// + /// Mutation sensitivity: narrowing the denied filter to + /// `event_kind = expected_event_kind` (kind 1) removes kind 6 from the + /// forbidden set, making this negative green — COMMIT succeeds when it must + /// not, failing `expect_err`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_wrong_kind_event_side() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("wrong-kind-evt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // Commit a denied enroll receipt in auto-commit mode. No deferred trigger + // is active after this commit; the receipt-side trigger fires only within + // the transaction that inserts the receipt. + let op_id = uuid::Uuid::new_v4(); + let fp = vec![0xB0_u8; 32]; + 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, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xB1_u8; 32]) + .bind(vec![0xB2_u8; 32]) + .execute(&pool) + .await + .expect("denied receipt must commit alone in auto-commit mode"); + + // New transaction: insert only a kind-6 (retired) event for the same + // operation. The event-side trigger is the only active deferred trigger. + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for wrong-kind event-side test"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin event-side wrong-kind transaction"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + // event_kind 6 (retired) — wrong kind for the denied enroll receipt + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xB3_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xB4_u8; 64]) // canonical_envelope + .bind(vec![0xB5_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); + + let wrong_kind_evt_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( + "kind-6 event paired with a committed denied enroll receipt must be \ + rejected at COMMIT by the event-side trigger", + ); + assert_eq!( + wrong_kind_evt_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event \ + for wrong kind (6) against denied receipt, got: {wrong_kind_evt_err}" + ); + } } diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 29eef88402..73e5e4fe56 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -454,6 +454,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,10 +481,47 @@ 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 + } +} + impl Db { /// Creates a new `Db` by connecting a Postgres pool with the given config. /// @@ -486,7 +533,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 +558,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 +610,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 diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index ecdc983a4a..8349fb5387 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -1,7 +1,7 @@ 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"; @@ -2180,10 +2180,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 +2191,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 +2205,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 +2249,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-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index f13b7d507a..d963a7f261 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)) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5dbb2aaf50..31fdb019bf 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(); @@ -2921,14 +2989,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 +3045,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 +3102,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 +3127,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()); diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index a8848af295..c29df6746b 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/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index ec7af3aac6..94fd7f8758 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?), @@ -3652,7 +3663,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 +3674,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 +3697,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 +3715,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 +3734,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 +3756,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 d09c7fc611..e3d05165e0 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, diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 7a9f8816fe..780532ec5d 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 204ec360c3..b7a0458f5a 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}"); @@ -236,9 +277,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 +303,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 +369,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 f19ac17d4c..b59fd840c6 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, diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index a3d5a6c729..c7fa09bebd 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 de8f1e1459..06d3a32b43 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() diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 5fcfe70b91..f6f2aaa913 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; @@ -600,7 +604,7 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar Ok(p) => p, Err(_) => { conn.send(request_rejection_message( - Some(&sub_id), + RejectionTarget::Subscription(&sub_id), "rate-limited: too many concurrent requests", )); return; @@ -621,7 +625,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,105 +647,140 @@ 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), - } -} - -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; - 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; - } - } - - true -} - -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 - } - } -} - #[cfg(test)] -mod tests { +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) + } + + /// 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, + }) + } + + 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:?}"), + } + } + + /// 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"); + } + + /// 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); + + 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"); + } + + /// 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 { messages: Vec, @@ -834,19 +874,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 127f1fc40e..02e2cc03a6 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/lib.rs b/crates/buzz-relay/src/lib.rs index 800433a849..5feb3f5774 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; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d9432589f4..d81602e201 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -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!( @@ -183,7 +195,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}") @@ -366,10 +379,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"); @@ -2052,10 +2062,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 +2098,67 @@ mod tests { assert!(tick_count.load(std::sync::atomic::Ordering::Relaxed) <= 1); } + #[tokio::test] + #[ignore = "requires Postgres"] + 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"); + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/rejection.rs b/crates/buzz-relay/src/rejection.rs new file mode 100644 index 0000000000..96b8074e55 --- /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/state.rs b/crates/buzz-relay/src/state.rs index ab3f1d8c7e..d1374e11b8 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1346,11 +1346,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 +1386,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 +1425,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 +1465,131 @@ mod tests { Arc::new(state) } + #[tokio::test] + #[ignore = "requires Postgres"] + 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"); + } + #[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/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 8ce23a2e8e..4ceb3b3930 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); @@ -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,10 +355,13 @@ 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) @@ -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 { //! 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 sink = Arc::new(RelayActionSink::new(&state)); + state.workflow_engine.set_action_sink(sink); - 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 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 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 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(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), ) @@ -972,6 +1245,7 @@ mod integration_tests { community, &channel_hex, "workflow reply", + "workflow reply", &author_hex, Some(&parent_hex), ) @@ -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-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 2dff81bcf7..f8a994bd0c 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -165,91 +165,17 @@ 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)?; - - 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()) - })?; - let conditions = arr[2] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; - let sig_hex = arr[3] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; - - let owner_pubkey = PublicKey::from_hex(owner_pubkey_hex) - .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; - - validate_conditions(conditions)?; - - if owner_pubkey == *agent_pubkey { - return Err(SdkError::InvalidInput( - "owner and agent pubkeys must differ (self-attestation rejected)".into(), - )); - } - - let sig = Signature::from_str(sig_hex) - .map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?; - - let preimage = build_preimage(agent_pubkey, conditions); - let message = hash_preimage(&preimage); - - let xonly = owner_pubkey.xonly().map_err(|e| { - SdkError::InvalidInput(format!("owner pubkey xonly conversion failed: {e}")) - })?; - SECP256K1 - .verify_schnorr(&sig, &message, &xonly) - .map_err(|e| SdkError::InvalidInput(format!("signature verification failed: {e}")))?; - - Ok(owner_pubkey) +struct ParsedAuthTag { + owner_pubkey_hex: String, + conditions: String, + sig_hex: String, } -/// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the -/// signature. -/// -/// Validates structure only: -/// - Exactly 4 elements -/// - First element is `"auth"` -/// - Second element is a 64-character hex string (owner pubkey) -/// - Fourth element is a 128-character hex string (signature) -/// -/// This is the fast path used at MCP startup — no crypto is performed. -/// -/// # Errors -/// -/// Returns [`SdkError::InvalidInput`] for any structural violation. -pub fn parse_auth_tag(json_str: &str) -> Result { +/// 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 { @@ -273,7 +199,7 @@ pub fn parse_auth_tag(json_str: &str) -> Result { })?; 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 must be 64 lowercase hex chars, got {:?}", owner_pubkey_hex ))); } @@ -281,7 +207,6 @@ pub fn parse_auth_tag(json_str: &str) -> Result { 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] @@ -289,13 +214,134 @@ pub fn parse_auth_tag(json_str: &str) -> Result { .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 {}", + "signature must be 128 lowercase 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}"))) + Ok(ParsedAuthTag { + owner_pubkey_hex: owner_pubkey_hex.to_owned(), + conditions: conditions.to_owned(), + sig_hex: sig_hex.to_owned(), + }) +} + +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( + "owner and agent pubkeys must differ (self-attestation rejected)".into(), + )); + } + + let sig = Signature::from_str(&parsed.sig_hex) + .map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?; + let preimage = build_preimage(agent_pubkey, &parsed.conditions); + let message = hash_preimage(&preimage); + + let xonly = owner_pubkey.xonly().map_err(|e| { + SdkError::InvalidInput(format!("owner pubkey xonly conversion failed: {e}")) + })?; + SECP256K1 + .verify_schnorr(&sig, &message, &xonly) + .map_err(|e| SdkError::InvalidInput(format!("signature verification failed: {e}")))?; + + 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. +/// +/// Validates structure only: +/// - Exactly 4 elements +/// - First element is `"auth"` +/// - Second element is a 64-character hex string (owner pubkey) +/// - Fourth element is a 128-character hex string (signature) +/// +/// This is the fast path used at MCP startup — no crypto is performed. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] for any structural violation. +pub fn parse_auth_tag(json_str: &str) -> Result { + 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-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 079c27a913..b8c7f4dd80 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 5c712dcff7..90a6a02e02 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/desktop/package.json b/desktop/package.json index 1e93fd76a8..425fbbbd9d 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", diff --git a/desktop/scripts/build-protected-feature-artifacts.mjs b/desktop/scripts/build-protected-feature-artifacts.mjs new file mode 100644 index 0000000000..3de4830cee --- /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/demo-build-config.mjs b/desktop/scripts/demo-build-config.mjs new file mode 100644 index 0000000000..fd5c9ed2a1 --- /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 0000000000..db2ba568c7 --- /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/package-macos-dmg.sh b/desktop/scripts/package-macos-dmg.sh new file mode 100755 index 0000000000..7ecaf9502e --- /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 0000000000..dc1d8691e9 --- /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.toml b/desktop/src-tauri/Cargo.toml index 050c3af89d..f41fa2d6e3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,7 +63,7 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2cdd785c73..8b0e63f12b 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_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f5..7684355a5b 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/build_identity.rs b/desktop/src-tauri/src/build_identity.rs new file mode 100644 index 0000000000..ee84696c7f --- /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_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 13bcb5d4ef..3dd3a823f0 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -66,6 +66,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: Some("persona-1".to_string()), @@ -127,6 +128,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, diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index db0573acd7..0b6bf22a6a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -506,6 +506,7 @@ mod real_relay_tests { &agent, "Agent Probe", None, + None, Some(&auth_tag), ) .await diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27..f671983bbc 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_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 1f66f24c6a..07f19f9a20 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 d79e40bd20..7c382a663b 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 bb045b81a2..68f54f58ad 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -244,8 +244,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 +299,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 diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index acee23f2f3..cc8bc08da4 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -15,7 +15,7 @@ use crate::{ 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, }; @@ -486,7 +486,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 +637,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,9 +739,12 @@ 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, ) }; @@ -781,20 +784,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; diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 16a1538c75..193a1fb034 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), } } @@ -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); } @@ -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 17fadea82f..ef71321bed 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, @@ -314,15 +316,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 +368,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 +377,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 +388,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 +399,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 +417,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/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2..517e333b29 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/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index 91616b225c..2f19d1256e 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 189d2676c4..6a10a1f9ee 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 2080630742..b4438b67b7 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -452,7 +452,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 +687,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 a5ca5cd9b5..390e485077 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 ab93243755..e90df63731 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 81371e72ed..ac43a4719a 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 30e2ec266d..3e4fabbcf5 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 fa492b338b..331ec9d0d7 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 e7bd1597e6..c996c7ee2e 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -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 ff2b453529..55a64db59b 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 729222d383..0ad466fc1a 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -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 6292a4dd25..abf4bef443 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/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index f9b09b4bbb..46d0c8a99d 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); + let was_renamed = renamed.contains(&record.pubkey); + let update = prepare_linked_profile_update( + record, + &result, + was_renamed, + avatar_changed, + about_changed, + ); - 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; - } - - 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 edef958cef..7aedcb25ef 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/project_repo_paths.rs b/desktop/src-tauri/src/commands/project_repo_paths.rs index 4193327c01..3fd4bbcaf8 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/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 26f6450c56..9c57ce12b5 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 b1c93a283e..13c7f6ae81 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 f3e0bc708a..d52e71aeee 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 bd30cdacc2..2235bd0b2b 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 941f725c50..7f4d31a653 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 71f841d580..a6e5a7d2d7 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/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 83ac7e59ff..614c62e1aa 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 84a08c4c64..da960f3a2d 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 0e718079a3..29e74cfb50 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 8d37028573..5fcf66a458 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 f9f7065769..09154d5237 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 699ffbe459..5f70b1f3f3 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 d4f3013496..d431937229 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; @@ -128,7 +129,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); } } @@ -405,7 +406,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", ()); } 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 0000000000..af0a406404 --- /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 ce30dcae85..85f34260ce 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 4b734ce159..abe48e49fa 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 8fd631b5b5..131966409b 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 02b4151da3..31dc365a77 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -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/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 5fe86e9cf8..f598888c60 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 @@ -65,6 +65,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { fn test_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "test".to_string(), name: "Test Agent".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/definition_validation.rs b/desktop/src-tauri/src/managed_agents/definition_validation.rs index e063eb85cd..17e75d7bda 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 78592357c9..1ee7e6e556 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,8 +1,7 @@ -use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -use std::time::{Duration, Instant}; +use std::time::Duration; use crate::managed_agents::{ buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir, @@ -10,6 +9,7 @@ use crate::managed_agents::{ HarnessSource, }; mod auth_status_cache; +mod bounded_command; mod login_shell; mod presets; mod runtime_metadata; @@ -593,6 +593,16 @@ pub fn resolve_command_cached(command: &str) -> Option { if let Some(managed) = resolve_buzz_managed_command(command) { return Some(managed); } + // Bundled sidecars (e.g. `buzz-agent`) ship next to the app executable, so + // `resolve_workspace_command` finds them with a filesystem stat and no + // login-shell spawn — the same class of work the managed-shim check above + // already performs. Without this the cheap path could never see the sidecar + // until a forced discovery warmed the resolve cache, so `buzz-agent` (which + // cannot legitimately be missing) reported "not installed" at every cold + // launch across the create/edit and agent-defaults surfaces. + if let Some(workspace) = resolve_workspace_command(command) { + return Some(workspace); + } resolve_cache() .lock() .ok() @@ -822,10 +832,9 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { /// Run a CLI auth probe with a 10-second process-level timeout. /// -/// Spawns the probe CLI as a child process. Stdout and stderr are drained on -/// background threads to prevent pipe-buffer deadlock. On timeout the child is -/// killed and `Unknown` is returned; no orphaned threads or processes are left -/// behind. Returns `Unknown` on timeout. +/// On timeout or spawn failure the child is killed and `Unknown` is returned; +/// no orphaned threads or processes are left behind (see +/// [`bounded_command::output_with_timeout`]). fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; @@ -836,81 +845,17 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { if let Some(ref path) = augmented_path { command.env("PATH", path); } - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - crate::util::configure_no_window(&mut command); + // Window suppression is owned by `output_with_timeout`'s spawn + // (`BOUNDED_CREATION_FLAGS` carries `CREATE_NO_WINDOW`); a + // `configure_no_window` call here would be clobbered by that later + // `creation_flags` set, so it is deliberately omitted. - let mut child = match command.spawn() { - Ok(c) => c, - Err(_) => return AuthStatus::Unknown, + let Some(output) = bounded_command::output_with_timeout(command, Duration::from_secs(10)) + else { + return AuthStatus::Unknown; }; - // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - - let stdout_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_end(&mut buf); - } - }); - let stderr_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stderr_pipe { - let _ = pipe.read_to_end(&mut buf); - } - buf - }); - - // Save PID for kill-on-timeout before moving child into the wait thread. - let child_pid = child.id(); - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let _ = tx.send(child.wait()); - }); - - // 10-second timeout for auth probes. - let deadline = Instant::now() + Duration::from_secs(10); - let exit_status = loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - #[cfg(unix)] - unsafe { - libc::kill(child_pid as i32, libc::SIGTERM); - } - #[cfg(not(unix))] - let _ = child_pid; - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - match rx.recv_timeout(Duration::from_millis(100).min(remaining)) { - Ok(Ok(status)) => break status, - Ok(Err(_)) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - } - }; - - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let stderr_bytes = stderr_thread.join().unwrap_or_default(); - - match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) { + match cli_probe::classify_probe_output(&output.stderr, output.status.success()) { cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid { diff --git a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs new file mode 100644 index 0000000000..18286d62b1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs @@ -0,0 +1,999 @@ +//! Run a child process to completion under a hard wall-clock deadline. +//! +//! Every spawn on the discovery path — the CLI auth probes and the login-shell +//! PATH lookups — must return in bounded time no matter how the child behaves. +//! A login shell that blocks on an interactive prompt, a child that traps +//! `SIGTERM`, or a forked descendant that keeps a pipe open must not be able to +//! stall discovery; that stall is what left "Check again" spinning forever. + +use std::io::{ErrorKind, Read}; +use std::process::{ChildStderr, ChildStdout, Command, ExitStatus, Output, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// Poll interval while waiting for the child to exit. +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Idle backoff for a nonblocking Unix drain that has no bytes available and +/// has not yet been told to stop. Short so a running child's output is pulled +/// promptly and the post-teardown join returns quickly. +#[cfg(unix)] +const DRAIN_IDLE_POLL: Duration = Duration::from_millis(5); + +/// Maximum bytes retained across stdout + stderr for one bounded probe. +/// +/// Discovery output is tiny — a version string, an auth-status word, a PATH +/// lookup. A probe that emits more than this is noisy or hostile. The ceiling +/// is enforced *in the drain sink* (see [`spawn_drain`]): each stream is pulled +/// on its own thread into a capped buffer, the limit is checked the moment a +/// bounded read crosses it, and the probe is failed closed — so an over-cap +/// payload is never retained in memory (and, since output goes to pipes not +/// temp files, never written to disk). The ceiling is *aggregate*, not +/// per-stream, so a probe cannot double it by splitting output across stdout +/// and stderr. +const CAPTURE_LIMIT: u64 = 1 << 20; // 1 MiB + +/// Grace period between the initial `SIGTERM` and the escalating `SIGKILL` for a +/// timed-out process group. Long enough for a well-behaved child to flush and +/// exit cleanly, short enough that a signal-ignoring one is reaped promptly. +#[cfg(unix)] +const KILL_GRACE: Duration = Duration::from_millis(500); + +/// Freeze the child so the Job Object can take ownership before any child code +/// runs (see [`BoundedChild::spawn`]). +#[cfg(windows)] +const CREATE_SUSPENDED: u32 = 0x0000_0004; + +/// Suppress the console window a GUI-spawned console child would otherwise +/// flash — the same suppression [`crate::util::configure_no_window`] applies to +/// non-bounded spawns. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// The exact creation flags every bounded child is spawned with. +/// +/// `Command::creation_flags` *replaces* rather than accumulates (std ORs only +/// `CREATE_UNICODE_ENVIRONMENT` afterward), and [`BoundedChild::spawn`] is the +/// last writer before spawn, so a caller's earlier `configure_no_window` is +/// wiped. This constant therefore has to carry every flag a bounded child +/// needs, and owning both here keeps the window-suppression contract in one +/// place instead of split between the caller and the helper. +#[cfg(windows)] +const BOUNDED_CREATION_FLAGS: u32 = CREATE_SUSPENDED | CREATE_NO_WINDOW; + +/// Compile-time guard: the bounded flags must always carry *both* bits. A +/// future edit that drops `CREATE_NO_WINDOW` (reintroducing the console-flash +/// regression) or `CREATE_SUSPENDED` (reopening the spawn-to-assign race) fails +/// the build on Windows rather than shipping silently. +#[cfg(windows)] +const _: () = { + assert!(BOUNDED_CREATION_FLAGS & CREATE_SUSPENDED == CREATE_SUSPENDED); + assert!(BOUNDED_CREATION_FLAGS & CREATE_NO_WINDOW == CREATE_NO_WINDOW); +}; + +/// A spawned child plus ownership of its descendant tree, torn down on *every* +/// exit path — timeout, error, or successful exit. The two platforms establish +/// ownership differently, and the guarantee is deliberately asymmetric — the +/// adjudicated design, not an oversight: +/// +/// - **Unix:** the child leads its own process group (`process_group(0)`), so +/// `killpg` reaches every descendant that has not left the group. A +/// `setsid`/`setpgid` escapee holding a pipe is *not* owned and may survive +/// one probe, yet never hangs the helper (see [`output_with_timeout`]). +/// - **Windows:** the child is spawned `CREATE_SUSPENDED`, assigned to a +/// kill-on-close Job Object while frozen, then resumed. The job owns the root +/// before any descendant can exist and is created without breakaway, so no +/// writer can escape it — a hard whole-tree guarantee. Closing that job reaps +/// the whole tree *even after the root has exited* — the distinction that +/// makes `taskkill /T ` (a live-root lookup) unfit for the success path. +/// This mirrors the Job Object discipline the harness uses to reap its 24 +/// agent workers (`process_lifecycle.rs`). +struct BoundedChild { + child: std::process::Child, + /// The kill-on-close job that owns the whole tree. Taken and dropped by + /// `kill_tree` so the reap happens exactly once. Spawn is fail-closed: if + /// the job cannot be created, assigned, or the child resumed, the child is + /// terminated and `spawn` returns `None` rather than running unowned. + #[cfg(windows)] + job: Option, +} + +impl BoundedChild { + /// Spawn `command`, establishing tree ownership before the child can run. + /// Returns `None` if the spawn fails or — on Windows — if the job cannot be + /// created, assigned, or the frozen child resumed; in every such case the + /// child is terminated and reaped before returning, so no unowned process + /// survives. + fn spawn(mut command: Command) -> Option { + // Run the child in its own process group so the whole tree can be torn + // down as a unit, not just a direct child that may have forked workers. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + + // Spawn frozen so the Job Object can take ownership before any child + // code runs and forks a descendant that would escape the job. The flags + // are set here as the last writer before spawn; `Command::creation_flags` + // replaces rather than ORs, so `BOUNDED_CREATION_FLAGS` must itself carry + // `CREATE_NO_WINDOW` — a caller's earlier `configure_no_window` would be + // clobbered otherwise, flashing a console window on GUI discovery. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(BOUNDED_CREATION_FLAGS); + } + + // `mut` is used only on the Windows fail-closed path (kill/wait on the + // frozen child); Unix moves the child unmodified into `Self`. + #[cfg_attr(not(windows), allow(unused_mut))] + let mut child = command.spawn().ok()?; + + #[cfg(windows)] + let job = { + // Assign the frozen child to a kill-on-close job, then resume it. + // Any failure is fail-closed: terminate + reap the still-owned + // child and abort the spawn, never run it unowned to the deadline. + let Some(job) = crate::managed_agents::create_job_for_child(child.id()) else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + if !crate::managed_agents::resume_process(child.id()) { + // Dropping the job kills the still-suspended child via + // kill-on-close; reap it so no zombie lingers. + drop(job); + let _ = child.wait(); + return None; + } + job + }; + + Some(Self { + child, + #[cfg(windows)] + job: Some(job), + }) + } + + fn try_wait(&mut self) -> std::io::Result> { + self.child.try_wait() + } + + /// Timeout teardown: a graceful `SIGTERM` to the group and a bounded grace + /// period for a clean flush on Unix, then the unconditional forced kill. + /// Windows has no group signal, so it goes straight to the forced kill. + fn terminate_timed_out(&mut self) { + #[cfg(unix)] + { + // SAFETY: `killpg` on the group led by the child; an ignored result + // is intentional — the group may already be gone (ESRCH). + unsafe { + libc::killpg(self.child.id() as i32, libc::SIGTERM); + } + std::thread::sleep(KILL_GRACE); + } + self.kill_tree(); + } + + /// Forcibly reap the whole tree. Idempotent and safe on an already-exited + /// tree. Runs on every exit path — including success, because a login shell + /// or auth CLI can background a descendant that outlives the leader while + /// still holding the captured-output descriptors. + fn kill_tree(&mut self) { + #[cfg(unix)] + // SAFETY: `killpg` on the group led by the child; ignored result is + // intentional — `ESRCH` on a dead group is the success case. + unsafe { + libc::killpg(self.child.id() as i32, libc::SIGKILL); + } + #[cfg(windows)] + // Closing the kill-on-close job reaps every descendant, even once the + // root has exited — which `taskkill /T ` cannot. `spawn` is + // fail-closed, so the job is always present until this first take; + // a later take is a no-op (the tree is already reaped). + if let Some(job) = self.job.take() { + drop(job); + } + } + + /// Reap the direct child so no zombie lingers after the tree is killed. + fn reap(&mut self) { + let _ = self.child.wait(); + } + + /// Take the captured stdout pipe. `Some` because [`output_with_timeout`] + /// configures `Stdio::piped()` before spawn. + fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + /// Take the captured stderr pipe. + fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } +} + +/// Set a file descriptor nonblocking so a read on it returns `WouldBlock` +/// instead of parking when no bytes are available. Returns `false` on any +/// `fcntl` failure, which the caller treats as fail-closed. +#[cfg(unix)] +fn set_nonblocking(f: &F) -> bool { + let fd = f.as_raw_fd(); + // SAFETY: `fd` is owned by `f` for the duration of this call; `F_GETFL` / + // `F_SETFL` read and set the descriptor's flags without transferring + // ownership or touching any other resource. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 { + return false; + } + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) == 0 + } +} + +/// Drain one child stream on its own thread into a buffer capped by the shared +/// aggregate budget, so the sink itself — not a post-hoc size sample — enforces +/// [`CAPTURE_LIMIT`]. +/// +/// Continuous draining keeps the pipe buffer from filling, so the child can +/// never block on a full pipe while we poll it. Retention is bounded: `total` +/// reserves a disjoint byte range per chunk across both streams, so the sum of +/// both buffers never exceeds the aggregate cap. The moment a read crosses the +/// cap, `overflow` is set and the drain returns immediately — it does not keep +/// reading, so a writer that keeps the pipe continuously readable cannot spin +/// this loop forever (it must cross the finite cap). A read error other than +/// `Interrupted`/`WouldBlock` returns `Err`, which the caller treats as +/// fail-closed. +/// +/// **Bounded completion differs by platform, because tree ownership does.** +/// - **Unix:** the read end is nonblocking (see [`set_nonblocking`]). A killed +/// in-group writer's descriptors close, so the read reaches EOF (`Ok(0)`) and +/// the thread returns normally. But `kill_tree` is a `killpg` on the child's +/// group, which does *not* reach a descendant that left the group via +/// `setsid`/`setpgid` while retaining the pipe; that writer keeps the write +/// end open and EOF never comes. So once teardown has set `stop`, a +/// `WouldBlock` (nothing more buffered) ends the drain rather than waiting on +/// that escaped writer forever. This is what makes bounded return hold +/// *without* depending on every inherited writer exiting — the correction to +/// the round-8 blocking-EOF design. +/// - **Windows:** the read blocks to EOF. That is sound because the whole tree +/// is owned by a kill-on-close Job Object created without +/// `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, so no descendant can escape the job; job +/// close reaps every writer and the read reaches EOF. `stop` is unused there. +fn spawn_drain( + mut reader: R, + total: Arc, + overflow: Arc, + stop: Arc, +) -> JoinHandle>> { + // `stop` gates only the nonblocking Unix drain; the Windows path blocks to + // the job-close EOF and never consults it. + #[cfg(windows)] + let _ = &stop; + std::thread::spawn(move || { + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + match reader.read(&mut chunk) { + Ok(0) => return Ok(buf), + Ok(n) => { + // Atomically reserve [prev, prev + n) of the shared budget; + // `prev` is unique per call, so the two streams keep + // disjoint ranges and their retained bytes sum to <= cap. + let prev = total.fetch_add(n as u64, Ordering::Relaxed); + if prev.saturating_add(n as u64) > CAPTURE_LIMIT { + overflow.store(true, Ordering::Relaxed); + let keep = CAPTURE_LIMIT.saturating_sub(prev).min(n as u64) as usize; + buf.extend_from_slice(&chunk[..keep]); + // Overflow: the result is already fail-closed, so nothing + // still in the pipe is worth preserving. Return NOW rather + // than draining to EOF — this is what bounds the `Ok(n)` + // path against a writer that keeps the pipe continuously + // readable, which would otherwise never reach the + // `WouldBlock`/`stop` check below and hang the join. It is + // safe to stop draining: the poll loop sees `overflow` and + // kills the tree, and a writer that then blocks on a full + // pipe dies to `killpg`/job-close. Do NOT "fix" that + // blocked-writer case by resuming an unbounded drain here. + return Ok(buf); + } + buf.extend_from_slice(&chunk[..n]); + } + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + // Nonblocking read (Unix only): no bytes available right now. + // After teardown, an escaped out-of-group writer is the only + // thing that could still hold the pipe open, so stop draining it + // rather than block the join forever; otherwise back off and + // retry so a running child's later output is still captured. + #[cfg(unix)] + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if stop.load(Ordering::Relaxed) { + return Ok(buf); + } + std::thread::sleep(DRAIN_IDLE_POLL); + } + Err(e) => return Err(e), + } + } + }) +} + +/// Run `command` to completion, bounded by `timeout`. +/// +/// Returns `Some(output)` when the child exits within the deadline, `None` when +/// it fails to spawn, exceeds the deadline, or breaches the capture ceiling. +/// Guarantees a bounded return regardless of child cooperation: +/// +/// - **Sink-enforced capture bound.** Stdout and stderr are piped to two drain +/// threads that read into buffers capped by a shared aggregate budget +/// ([`spawn_drain`]); nothing over [`CAPTURE_LIMIT`] is ever retained. On a +/// breach the poll loop fails closed — kill the tree, return `None` — so a +/// noisy or hostile probe cannot force unbounded memory (and, with pipes +/// rather than temp files, cannot fill the disk either). Continuous draining +/// also keeps the pipe buffer from filling, so the child can never block on a +/// full pipe while we poll. +/// - **Bounded drain completion without depending on writer death.** Tree +/// teardown runs on *every* exit path before the drains are joined — +/// [`BoundedChild::kill_tree`] on timeout, error, cap breach, *and* success. +/// But teardown alone does not guarantee EOF on Unix: `kill_tree` is a +/// `killpg` on the child's process group, and a descendant that left the +/// group (`setsid`/`setpgid`) while retaining the pipe survives it and keeps +/// the write end open. So the drains do not rely on EOF from every writer: +/// the Unix reads are nonblocking, and after teardown sets the shared `stop` +/// flag a `WouldBlock` (no more buffered bytes) ends each drain. An escaped +/// writer is allowed to survive; the join still returns promptly. On Windows +/// the reads block to EOF, which is sound because the kill-on-close Job Object +/// is created without breakaway, so no writer can escape the job. This is the +/// correction to the round-8 design, whose blocking Unix reads could hang the +/// join forever on a group-escaping writer. +/// - **No wait hang.** The child is polled with [`Child::try_wait`] against the +/// deadline rather than blocked on with `wait()`. +/// - **Tree termination on every exit path.** [`BoundedChild`] tears the tree +/// down whether the child times out, errors, breaches the cap, *or exits +/// successfully* — a login-shell rc file or auth CLI can legitimately +/// background a descendant (`worker &`) that would outlive discovery. +/// Ownership is a hard whole-tree guarantee on Windows but only the child's +/// process group on Unix (the group-escapee case bounded by the drain rule +/// above) — the adjudicated asymmetry. The timeout path additionally sends a +/// graceful `SIGTERM` and a grace period before the kill. +pub(crate) fn output_with_timeout(mut command: Command, timeout: Duration) -> Option { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = BoundedChild::spawn(command)?; + + let stdout_pipe = child.take_stdout(); + let stderr_pipe = child.take_stderr(); + + // Unix: make the parent read ends nonblocking so a drain can be told to stop + // (post-teardown) instead of parking forever on a group-escaping writer that + // still holds the pipe. Fail closed if the fd cannot be reconfigured — the + // child is still fully owned here, so cleanup is just kill + reap. + #[cfg(unix)] + { + let stdout_ok = match stdout_pipe.as_ref() { + Some(p) => set_nonblocking(p), + None => true, + }; + let stderr_ok = match stderr_pipe.as_ref() { + Some(p) => set_nonblocking(p), + None => true, + }; + if !(stdout_ok && stderr_ok) { + child.kill_tree(); + child.reap(); + return None; + } + } + + // Shared drain state: one aggregate byte budget across both streams, an + // overflow flag the poll loop watches so a streaming producer that never + // exits is failed closed the moment it crosses the cap, and a stop flag that + // teardown raises to end the nonblocking Unix drains. + let total = Arc::new(AtomicU64::new(0)); + let overflow = Arc::new(AtomicBool::new(false)); + let stop = Arc::new(AtomicBool::new(false)); + let stdout_drain = + stdout_pipe.map(|s| spawn_drain(s, total.clone(), overflow.clone(), stop.clone())); + let stderr_drain = + stderr_pipe.map(|s| spawn_drain(s, total.clone(), overflow.clone(), stop.clone())); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => { + if Instant::now() >= deadline { + child.terminate_timed_out(); + break None; + } + // Fail closed on a capture breach *while the child runs*: the + // drain kept nothing over the cap; teardown below ends the + // drains so the join cannot hang. + if overflow.load(Ordering::Relaxed) { + child.kill_tree(); + break None; + } + std::thread::sleep(POLL_INTERVAL); + } + Err(_) => { + child.kill_tree(); + break None; + } + } + }; + + // Tree down on every path (timeout/error/overflow killed it above; a clean + // exit may still have backgrounded a descendant holding the pipe). Kill is + // idempotent, so calling it here on the success path is safe. Then raise + // `stop`: a killed in-group writer's pipe reaches EOF and ends its drain on + // its own, but a group-escaping writer never will — `stop` ends that drain + // on the next `WouldBlock` so the joins below return promptly. + child.kill_tree(); + child.reap(); + stop.store(true, Ordering::Relaxed); + + let stdout = join_drain(stdout_drain); + let stderr = join_drain(stderr_drain); + + // Fail closed if the child exited within the deadline but overran the cap in + // a final burst, or if either drain hit a read error (join_drain -> None). + let (status, stdout, stderr) = (status?, stdout?, stderr?); + if overflow.load(Ordering::Relaxed) { + return None; + } + + Some(Output { + status, + stdout, + stderr, + }) +} + +/// Join a drain thread, returning its captured bytes. `None` (fail closed) if +/// the stream was absent, the thread panicked, or the read errored. +fn join_drain(drain: Option>>>) -> Option> { + match drain { + Some(handle) => handle.join().ok()?.ok(), + None => Some(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + /// Drive `output_with_timeout` on its own thread under an independent + /// wall-clock `bound` — the real outer bound, unreachable by an inline `elapsed()` assertion if the helper hangs. + /// The raw result lets the Windows sites fold transcripts into the expiry panic. + #[cfg(any(unix, windows))] + fn run_watchdogged_raw( + cmd: Command, + timeout: Duration, + bound: Duration, + ) -> Result, mpsc::RecvTimeoutError> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(output_with_timeout(cmd, timeout)); + }); + rx.recv_timeout(bound) + } + #[cfg(unix)] + fn run_watchdogged(cmd: Command, timeout: Duration, bound: Duration) -> Option { + run_watchdogged_raw(cmd, timeout, bound) + .unwrap_or_else(|_| panic!("output_with_timeout did not return within {bound:?}")) + } + + /// True while a Unix process (or a reaped-but-not-waited zombie under this + /// test process) still exists. `kill(pid, 0)` probes existence without + /// signalling. Descendants reparent to init on exit, so a survivor stays + /// probeable; once `kill_tree` reaps it, the pid is gone (ESRCH). + #[cfg(unix)] + fn pid_alive(pid: i32) -> bool { + unsafe { libc::kill(pid, 0) == 0 } + } + + #[cfg(unix)] + #[test] + fn returns_output_for_fast_command() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "printf hi; printf oops 1>&2"]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("a fast command must complete within the timeout"); + assert!(out.status.success()); + assert_eq!(out.stdout, b"hi"); + assert_eq!(out.stderr, b"oops"); + } + + // Adversarial: a child that traps and ignores SIGTERM. The old + // wait-thread + lone-SIGTERM helper never returned for this input; the + // process-group SIGKILL escalation must reap it inside the grace period. + // The watchdog thread is the real bound — the helper hanging fails the + // test rather than hanging it. + #[cfg(unix)] + #[test] + fn kills_sigterm_ignoring_child_within_bound() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "trap '' TERM; while :; do sleep 1; done"]); + let result = run_watchdogged(cmd, Duration::from_millis(200), Duration::from_secs(5)); + assert!(result.is_none(), "a timed-out child must yield None"); + } + + // Adversarial (success path): the direct child exits 0 but backgrounds a + // descendant that keeps writing to the inherited stdout/stderr forever. + // Two guarantees under test: (1) the drain returns rather than blocking on + // the descendant, and (2) `kill_tree` reaps that descendant before + // returning, so no survivor keeps consuming CPU after discovery reports + // success. This is the pass-2 leak Thufir proved with `(yes) & exit 0`. + #[cfg(unix)] + #[test] + fn reaps_backgrounded_descendant_on_success() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + // Background a real child process (`sleep`), record ITS pid via `$!` + // (not `$$`, which in a subshell is the invoking shell), then exit 0. + // The leader waits until the pid is recorded so the test can read it + // deterministically even though the success path kills the group at + // once. `$!` is the pass-2 `(yes) & exit 0` survivor, made observable. + let script = format!( + "sleep 30 & echo $! > '{pid_path}'; \ + until [ -s '{pid_path}' ]; do :; done; printf done; exit 0" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("the direct child exits, so this must return its output"); + assert!(out.status.success()); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("descendant must have recorded its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + // Give the reaped group a moment to fully disappear, then assert dead. + std::thread::sleep(Duration::from_millis(200)); + assert!( + !pid_alive(descendant_pid), + "backgrounded descendant {descendant_pid} must be reaped on success, but it survived" + ); + } + + // Adversarial (timeout path): a SIGTERM-ignoring leader that backgrounds a + // descendant, both looping forever. The leader's process group is killed on + // timeout, so the descendant (same group) must die too. The descendant is a + // real child process whose PID is recorded via `$!`, so the test proves the + // actual descendant — not the already-reaped leader — reaches ESRCH. + #[cfg(unix)] + #[test] + fn reaps_descendant_on_timeout() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + let script = format!( + "trap '' TERM; sleep 300 & echo $! > '{pid_path}'; \ + while :; do sleep 1; done" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); + assert!(result.is_none(), "a timed-out tree must yield None"); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("descendant must have written its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + std::thread::sleep(Duration::from_millis(200)); + assert!( + !pid_alive(descendant_pid), + "backgrounded descendant {descendant_pid} must be group-killed on timeout, but it survived" + ); + } + + // Deterministic seam regression (Thufir's finding): a drain fed a reader + // that stays continuously readable — every `read` returns `Ok(8192)`, never + // `WouldBlock` — must still complete, because the `stop`/`WouldBlock` check + // alone never fires on such a reader. The bound comes from the `Ok(n)` path + // returning the instant the aggregate cap is crossed. No real process and no + // scheduler timing: the reader is a pure in-test `Read` impl, so this pins + // the control flow rather than relying on a descendant eventually blocking. + // With the round-9-initial code (which kept reading after overflow) the + // drain never returns and the join below hangs past the watchdog. + #[test] + fn overflow_bounds_a_continuously_readable_drain() { + /// A reader that is always ready with a full 8192-byte chunk. It never + /// returns 0 (EOF) or `WouldBlock`, so only the overflow return can end + /// a drain reading it. + struct AlwaysReady; + impl Read for AlwaysReady { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + for b in buf.iter_mut() { + *b = b'x'; + } + Ok(buf.len()) + } + } + + let total = Arc::new(AtomicU64::new(0)); + let overflow = Arc::new(AtomicBool::new(false)); + // `stop` set from the start: a correct drain must NOT depend on it here, + // since a continuously-ready reader never hits the `WouldBlock` arm that + // consults it. The overflow return is the only thing that can bound it. + let stop = Arc::new(AtomicBool::new(true)); + let drain = spawn_drain(AlwaysReady, total.clone(), overflow.clone(), stop); + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(drain.join()); + }); + let joined = rx + .recv_timeout(Duration::from_secs(2)) + .expect("a continuously-readable drain must be bounded by the capture cap"); + let buf = joined + .expect("drain thread must not panic") + .expect("drain read must not error"); + assert!( + overflow.load(Ordering::Relaxed), + "the drain must have tripped overflow" + ); + assert!( + buf.len() as u64 <= CAPTURE_LIMIT, + "retained bytes {} must not exceed the cap {CAPTURE_LIMIT}", + buf.len() + ); + } + + // Adversarial (group escape): the leader backgrounds a descendant that + // calls `setsid()` — leaving the leader's process group while retaining the + // inherited stdout — then sleeps 300s; the leader itself loops forever, so + // the helper times out. `kill_tree` is a `killpg` on the leader's group and + // cannot reach the escaped descendant, so its pipe write end stays open and + // never reaches EOF. The helper must still return within the outer watchdog + // and fail closed: the nonblocking drains stop on `WouldBlock` after + // teardown rather than blocking on that surviving writer. This is the exact + // primitive Thufir reproduced against the round-8 blocking-read design; with + // blocking reads the drain join hangs forever and `run_watchdogged` panics. + // + // Non-vacuous: the descendant is asserted *alive* after the helper returns, + // proving it genuinely escaped the `killpg` (so it was still holding the + // pipe at join time) — the return therefore came from the stop path, not + // from an EOF the kill happened to produce. The test then reaps it. + #[cfg(unix)] + #[test] + fn returns_when_escaped_descendant_retains_pipe() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + // The perl descendant `setsid()`s out of the leader's group, records its + // PID, writes a few bytes to the retained stdout, then sleeps. The + // leader waits until the PID is recorded (so the test can read it) and + // then loops forever, forcing the timeout path. + let script = format!( + "perl -MPOSIX -e 'POSIX::setsid() or die; open(my $f,\">\",$ARGV[0]) or die; \ + print $f $$; close $f; print \"x\" x 4096; sleep 300;' '{pid_path}' & \ + until [ -s '{pid_path}' ]; do :; done; while :; do sleep 1; done" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); + assert!( + result.is_none(), + "a timed-out probe must fail closed even when an escaped writer holds the pipe" + ); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("escaped descendant must have recorded its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + assert!( + pid_alive(descendant_pid), + "descendant {descendant_pid} was expected to survive the group kill (proving it escaped)" + ); + // Reap the escaped writer so the test leaves nothing behind. + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } + } + + // Adversarial (capture bound): a producer that streams zero bytes + // *indefinitely* — it never exits and never stops writing on its own, so + // the only thing that can end the probe is the in-flight ceiling check + // tripping `overflow`, killing the tree, and failing closed (None). + // + // The discriminator is `timeout >> bound`: the deadline is 60s but the + // watchdog fails the test at 10s, so a return within the bound proves the + // *cap* ended the probe, not the timeout. Neuter the overflow check and the + // helper runs until the 60s deadline, blowing the 10s watchdog. Pipe + // backpressure cannot end it either: the drains pull continuously, so `cat` + // would keep writing forever. Retention stays bounded by construction — + // `spawn_drain` reserves a disjoint byte range per chunk against the shared + // budget and discards everything past `CAPTURE_LIMIT` — so no over-cap + // payload is ever materialized even though the producer is infinite. + #[cfg(unix)] + #[test] + fn fails_closed_when_capture_exceeds_limit() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "exec cat /dev/zero"]); + let result = run_watchdogged(cmd, Duration::from_secs(60), Duration::from_secs(10)); + assert!( + result.is_none(), + "an unbounded producer must fail closed on the capture cap, well before the deadline" + ); + } + + // The complement of the bound: output at or under the ceiling still returns + // in full, so the limit rejects only genuine overruns. + #[cfg(unix)] + #[test] + fn returns_full_output_at_capture_limit() { + let mut cmd = Command::new("/bin/sh"); + // Comfortably under 1 MiB, emitted in one burst then a clean exit. + cmd.args(["-c", "head -c 4096 /dev/zero"]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("output under the limit must be returned"); + assert!(out.status.success()); + assert_eq!(out.stdout.len(), 4096); + } + + // ---- Windows tree-ownership verification (Will's box) ---------------- + // + // No CI lane executes Windows tests for this helper, so these are + // `#[ignore]`-gated for a sanctioned local run on a real Windows machine: + // + // cargo test -p buzz-desktop --lib bounded_command -- --ignored --nocapture + // + // Both assert on the actual PowerShell-recorded descendant PID (not the + // already-exited root), so neutering the Job Object ownership leaves that + // PID alive and fails the test — the mutation is observable. + + /// True while a Windows process still exists. Opens with the minimal + /// query right and reads its exit code: `STILL_ACTIVE` (259) means running, + /// any other code means exited. A failed open means the PID is gone. + #[cfg(windows)] + fn pid_alive(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + return false; + } + let mut code: u32 = 0; + let ok = GetExitCodeProcess(handle, &mut code); + CloseHandle(handle); + ok != 0 && code == STILL_ACTIVE as u32 + } + } + + /// Read a PID that a probe wrote to `path`, retrying briefly since the + /// descendant records it asynchronously. Dumps `logs` on failure so a remote + /// run diagnoses itself instead of panicking blind. + #[cfg(windows)] + fn read_recorded_pid(path: &str, logs: &[&str]) -> u32 { + for _ in 0..200 { + if let Ok(text) = std::fs::read_to_string(path) { + if let Ok(pid) = text.trim().parse::() { + return pid; + } + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!( + "descendant never recorded its PID at {path}\n{}", + dump_logs(logs) + ); + } + + /// Write a PowerShell payload to `path` as a `.ps1` file. Invoking these via + /// `powershell -File` avoids the Rust-std → cmd.exe → powershell quoting + /// gauntlet that silently mangled the inline `-Command` fixtures (the root + /// exited without its payload ever running), so the payload reaches + /// PowerShell verbatim. + #[cfg(windows)] + fn write_ps1(path: &std::path::Path, body: &str) { + std::fs::write(path, body).expect("write .ps1 payload"); + } + + /// Collect the named transcript files (each written by the fixture's + /// PowerShell) into one string for a self-diagnosing assert message. Missing + /// files are reported as such rather than skipped. + #[cfg(windows)] + fn dump_logs(paths: &[&str]) -> String { + let mut out = String::from("---- fixture transcripts ----\n"); + for p in paths { + out.push_str(&format!("[{p}]\n")); + match std::fs::read_to_string(p) { + Ok(text) if text.is_empty() => out.push_str("(empty)\n"), + Ok(text) => { + out.push_str(&text); + if !text.ends_with('\n') { + out.push('\n'); + } + } + Err(e) => out.push_str(&format!("(unreadable: {e})\n")), + } + } + out + } + + // Success path, run in a loop to hammer the spawn/assign race. A PowerShell + // root (no cmd.exe anywhere) launches a hidden, detached PowerShell + // descendant via `Start-Process -WindowStyle Hidden`; the descendant records + // its own PID and sleeps. The root then waits synchronously until the PID + // file is non-empty before exiting 0 — without that wait the root would exit + // in the same tick, the success path would close the kill-on-close job + // immediately, and the descendant would be reaped mid-cold-start before it + // could record its PID, starving the test of its evidence. The descendant is + // still born inside the job (suspend → assign → resume, no breakaway), so the + // reaping guarantee under test is unchanged; only the delivery mechanism (a + // `.ps1` via `-File`, not a mangled inline `-Command`) is fixed. Every assert + // dumps the PowerShell transcripts so a remote failure is self-diagnosing. + #[cfg(windows)] + #[test] + #[ignore = "requires a Windows host; run manually with --ignored"] + fn reaps_backgrounded_descendant_on_success_windows() { + for iteration in 0..25 { + let dir = tempfile::tempdir().expect("temp dir for fixture scripts"); + let pid_path = dir.path().join("descendant.pid"); + let child_ps1 = dir.path().join("child.ps1"); + let root_ps1 = dir.path().join("root.ps1"); + let root_log = dir.path().join("root.log"); + let child_log = dir.path().join("child.log"); + let pid_s = pid_path.to_str().expect("utf-8 pid path"); + let root_log_s = root_log.to_str().expect("utf-8 root log"); + let child_log_s = child_log.to_str().expect("utf-8 child log"); + + write_ps1( + &child_ps1, + &format!( + "$PID | Set-Content -Encoding ascii -Path '{pid_s}'\n\ + Add-Content -Path '{child_log_s}' -Value \"descendant $PID started\"\n\ + Start-Sleep -Seconds 30\n" + ), + ); + write_ps1( + &root_ps1, + &format!( + "Add-Content -Path '{root_log_s}' -Value \"root $PID launching descendant\"\n\ + Start-Process -FilePath 'powershell' -WindowStyle Hidden -ArgumentList \ + '-NoProfile','-ExecutionPolicy','Bypass','-File','{child}'\n\ + $deadline = (Get-Date).AddSeconds(15)\n\ + while (((-not (Test-Path '{pid_s}')) -or ((Get-Item '{pid_s}').Length -eq 0)) \ + -and (Get-Date) -lt $deadline) {{ Start-Sleep -Milliseconds 50 }}\n\ + Add-Content -Path '{root_log_s}' -Value \"root observed pid file, exiting\"\n\ + exit 0\n", + child = child_ps1.to_str().expect("utf-8 child path"), + ), + ); + + let mut cmd = Command::new("powershell"); + cmd.args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + root_ps1.to_str().expect("utf-8 root path"), + ]); + let out = run_watchdogged_raw(cmd, Duration::from_secs(20), Duration::from_secs(40)) + .ok() + .flatten() + .unwrap_or_else(|| { + panic!( + "iteration {iteration}: root exits, so this must return output\n{}", + dump_logs(&[root_log_s, child_log_s]) + ) + }); + assert!( + out.status.success(), + "iteration {iteration}: root must exit 0\nstdout={}\nstderr={}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + dump_logs(&[root_log_s, child_log_s]) + ); + + let descendant_pid = read_recorded_pid(pid_s, &[root_log_s, child_log_s]); + std::thread::sleep(Duration::from_millis(300)); + assert!( + !pid_alive(descendant_pid), + "iteration {iteration}: descendant {descendant_pid} must be reaped on success, but it survived\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + } + } + + // Timeout path: a PowerShell root launches a hidden, detached PowerShell + // descendant (records its PID, sleeps 300s), waits synchronously until the + // PID file is non-empty, then enters its own 300s block so the helper's + // deadline fires inside it. The helper must time out and close the job, + // reaping both. The synchronous wait is the evidence — the descendant's PID + // is recorded before the root reaches the block the deadline fires in, so the + // reap cannot kill it mid-cold-start and starve the assert. Same `.ps1` + // delivery as the success fixture (no cmd tokenizer), and every assert dumps + // the transcripts. + #[cfg(windows)] + #[test] + #[ignore = "requires a Windows host; run manually with --ignored"] + fn reaps_descendant_on_timeout_windows() { + let dir = tempfile::tempdir().expect("temp dir for fixture scripts"); + let pid_path = dir.path().join("descendant.pid"); + let child_ps1 = dir.path().join("child.ps1"); + let root_ps1 = dir.path().join("root.ps1"); + let root_log = dir.path().join("root.log"); + let child_log = dir.path().join("child.log"); + let pid_s = pid_path.to_str().expect("utf-8 pid path"); + let root_log_s = root_log.to_str().expect("utf-8 root log"); + let child_log_s = child_log.to_str().expect("utf-8 child log"); + + write_ps1( + &child_ps1, + &format!( + "$PID | Set-Content -Encoding ascii -Path '{pid_s}'\n\ + Add-Content -Path '{child_log_s}' -Value \"descendant $PID started\"\n\ + Start-Sleep -Seconds 300\n" + ), + ); + write_ps1( + &root_ps1, + &format!( + "Add-Content -Path '{root_log_s}' -Value \"root $PID launching descendant\"\n\ + Start-Process -FilePath 'powershell' -WindowStyle Hidden -ArgumentList \ + '-NoProfile','-ExecutionPolicy','Bypass','-File','{child}'\n\ + $deadline = (Get-Date).AddSeconds(15)\n\ + while (((-not (Test-Path '{pid_s}')) -or ((Get-Item '{pid_s}').Length -eq 0)) \ + -and (Get-Date) -lt $deadline) {{ Start-Sleep -Milliseconds 50 }}\n\ + Add-Content -Path '{root_log_s}' -Value \"root observed pid file, blocking\"\n\ + Start-Sleep -Seconds 300\n", + child = child_ps1.to_str().expect("utf-8 child path"), + ), + ); + + let mut cmd = Command::new("powershell"); + cmd.args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + root_ps1.to_str().expect("utf-8 root path"), + ]); + let result = run_watchdogged_raw(cmd, Duration::from_secs(20), Duration::from_secs(40)) + .unwrap_or_else(|_| { + panic!( + "watchdog expired — output_with_timeout hung on the timeout path\n{}", + dump_logs(&[root_log_s, child_log_s]) + ) + }); + assert!( + result.is_none(), + "a timed-out tree must yield None\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + + let descendant_pid = read_recorded_pid(pid_s, &[root_log_s, child_log_s]); + std::thread::sleep(Duration::from_millis(300)); + assert!( + !pid_alive(descendant_pid), + "descendant {descendant_pid} must be job-killed on timeout, but it survived\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs index d8f8e60354..c9109184d5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs @@ -6,9 +6,15 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::Duration; use super::is_executable_file; +/// Per-candidate wall-clock bound for a login-shell spawn. Matches the auth +/// probe's 10s discipline: long enough for a healthy interactive shell to +/// source its rc files, short enough that a wedged shell can't stall discovery. +const LOGIN_SHELL_TIMEOUT: Duration = Duration::from_secs(10); + /// Test-only spawn counter lives beside `discovery.rs`; import it here so the /// spawn-record call site stays byte-identical to the pre-extraction source. #[cfg(test)] @@ -34,14 +40,25 @@ pub(crate) fn login_shell_candidates() -> Vec { /// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). /// Returns trimmed stdout if the command succeeds with non-empty output. +/// +/// Each candidate shell is bounded by [`LOGIN_SHELL_TIMEOUT`]: a shell whose +/// startup blocks (an interactive prompt in `.zshrc`, a stalled network mount, +/// a credential helper waiting on input) is killed and treated as a miss so the +/// loop falls through to the next candidate rather than hanging the whole +/// discovery. Without this bound a single slow login shell froze the forced +/// pipeline indefinitely, which is what left "Check again" spinning forever. fn run_in_login_shell(args: &[&str]) -> Option { #[cfg(test)] login_shell_spawn_probe::record(); for shell in login_shell_candidates() { let mut cmd = Command::new(&shell); cmd.args(args); - crate::util::configure_no_window(&mut cmd); - let Ok(output) = cmd.output() else { + // Window suppression is owned by `output_with_timeout`'s spawn + // (`BOUNDED_CREATION_FLAGS` carries `CREATE_NO_WINDOW`); a + // `configure_no_window` call here would be clobbered by that later + // `creation_flags` set, so it is deliberately omitted. + let Some(output) = super::bounded_command::output_with_timeout(cmd, LOGIN_SHELL_TIMEOUT) + else { continue; }; if !output.status.success() { @@ -72,10 +89,25 @@ enum LoginShellPath { Probed(Option), } -fn path_cache() -> &'static std::sync::Mutex { +/// Cache plus a monotonic generation counter. `refresh_login_shell_path` bumps +/// the generation and resets the state together; a probe records the generation +/// it started under and may only publish its result while that generation is +/// still current. This stops a slow, pre-refresh probe from committing a stale +/// (often false-negative) PATH over the fresh value a post-refresh probe wrote. +struct PathCache { + generation: u64, + state: LoginShellPath, +} + +fn path_cache() -> &'static std::sync::Mutex { use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + Mutex::new(PathCache { + generation: 0, + state: LoginShellPath::Uninit, + }) + }) } fn fetch_login_shell_path_inner() -> Option { @@ -103,45 +135,112 @@ fn fetch_login_shell_path_inner() -> Option { /// to invalidate the cache so the next call re-fetches — e.g. after the user /// installs Node.js mid-session and clicks Retry. /// -/// The lock is never held while the login shell spawns: we check for a cached -/// value, release the lock, run the shell, then re-lock to write. Two concurrent -/// callers may both run the shell (last-writer-wins is fine — both produce the -/// same result), but neither blocks a concurrent agent spawn on the Mutex. +/// The lock is never held while the login shell spawns: we read the cached +/// value and the current generation, release the lock, run the shell, then +/// re-lock to publish. Publication is generation-guarded so a probe that +/// started before a [`refresh_login_shell_path`] can never overwrite the fresh +/// value: if the generation moved while the probe ran, its result is discarded. +/// Within one generation two callers may both probe; a failure/timeout result +/// (`None`) never clobbers an already-committed success, so a slow timeout can't +/// undo a peer's fresh PATH. +/// +/// The caller never returns its own local probe result: after publishing it +/// returns the value now in the cache. This closes two divergences where a +/// caller's own result contradicted the authoritative cache: +/// - same-generation timeout-vs-success — a peer committed a success while +/// our probe timed out (`None`); we return the peer's success, not `None`; +/// - a pre-refresh probe whose writeback was generation-rejected — its local +/// value is stale, so we re-probe under the new generation instead. pub fn login_shell_path() -> Option { - // Fast path: return cached result without spawning a shell. - { - let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - if let LoginShellPath::Probed(ref result) = *guard { - return result.clone(); + loop { + // Fast path: return the cached result and capture the generation the + // probe will run under, all under a single lock. + let generation = { + let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let LoginShellPath::Probed(ref result) = guard.state { + return result.clone(); + } + guard.generation + }; + + // Slow path: spawn shell outside any lock. + let result = probe_login_shell_path(); + + // Publish under our generation, then return whatever value is now + // authoritative. `None` means a refresh invalidated our generation + // mid-probe and no fresh value is cached yet, so our `result` is stale + // by definition — discard it and re-probe under the new generation. + // + // Termination: another lap requires another [`refresh_login_shell_path`] + // to land during a probe. Refreshes come only from discrete human + // actions (install/retry/Doctor re-run) and one-shot boot warm, so the + // loop cannot spin unbounded. + if let Some(committed) = publish_probe_result(generation, result) { + return committed; } } +} - // Slow path: spawn shell outside any lock. - let result = fetch_login_shell_path_inner(); +/// Real login-shell probe. A `cfg(test)` seam lets the race tests inject +/// deterministic probe results (and side effects) without spawning shells. +#[cfg(not(test))] +fn probe_login_shell_path() -> Option { + fetch_login_shell_path_inner() +} - // Write back; last-writer-wins is safe here. - { - let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Probed(result.clone()); +#[cfg(test)] +fn probe_login_shell_path() -> Option { + match path_cache_race_tests::take_injected_probe() { + Some(injected) => injected(), + None => fetch_login_shell_path_inner(), } +} - result +/// Commit a probe's `result` under the generation it started with, then report +/// the value the caller should return. +/// +/// A probe whose generation is stale (a [`refresh_login_shell_path`] ran while +/// it was probing) does not commit. Within a live generation a failure/timeout +/// (`None`) never overwrites an already-committed success. This is the sole +/// writer of a probed value, so the two race outcomes are decided here. +/// +/// Returns `Some(v)` — the now-cached probed value the caller must return +/// (its own commit, or a peer's success that superseded it) — or `None` when +/// the cache is `Uninit` because a refresh landed mid-probe, signalling the +/// caller to re-probe under the new generation. Commit and re-read happen under +/// one lock so no refresh can slip between them. +fn publish_probe_result(generation: u64, result: Option) -> Option> { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if guard.generation == generation { + let keep_committed_success = + result.is_none() && matches!(guard.state, LoginShellPath::Probed(Some(_))); + if !keep_committed_success { + guard.state = LoginShellPath::Probed(result); + } + } + match guard.state { + LoginShellPath::Probed(ref v) => Some(v.clone()), + LoginShellPath::Uninit => None, + } } /// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call /// re-fetches from a fresh login shell. /// /// Called before every install/retry operation and on Doctor Re-run so a -/// newly-installed tool becomes visible without restarting the app. +/// newly-installed tool becomes visible without restarting the app. Bumping the +/// generation revokes any in-flight probe's writeback, so a shell that started +/// before this refresh cannot recache its now-stale result. pub(crate) fn refresh_login_shell_path() { let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Uninit; + guard.generation = guard.generation.wrapping_add(1); + guard.state = LoginShellPath::Uninit; } #[cfg(test)] pub(crate) fn is_login_shell_path_uninit() -> bool { matches!( - *path_cache().lock().unwrap_or_else(|e| e.into_inner()), + path_cache().lock().unwrap_or_else(|e| e.into_inner()).state, LoginShellPath::Uninit ) } @@ -234,3 +333,178 @@ pub(crate) fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { let patch = patch_str.split('-').next()?.parse::().ok()?; Some((major, minor, patch)) } + +#[cfg(test)] +mod path_cache_race_tests { + use super::*; + use std::collections::VecDeque; + use std::sync::{Mutex, OnceLock}; + + /// A deterministic stand-in for one login-shell spawn. Returning it lets a + /// test drive `login_shell_path`'s slow path without a real shell, and run + /// side effects (a peer commit, a mid-probe refresh) at the exact moment a + /// probe would be executing. + pub(super) type InjectedProbe = Box Option + Send>; + + fn probe_queue() -> &'static Mutex> { + static Q: OnceLock>> = OnceLock::new(); + Q.get_or_init(|| Mutex::new(VecDeque::new())) + } + + /// Consumed by the `cfg(test)` `probe_login_shell_path` seam: each slow-path + /// probe pops the next injected result, falling back to the real shell when + /// the queue is empty (so unrelated cache tests still exercise real probing). + pub(super) fn take_injected_probe() -> Option { + probe_queue() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .pop_front() + } + + fn inject_probes(probes: Vec) { + let mut q = probe_queue().lock().unwrap_or_else(|e| e.into_inner()); + q.clear(); + q.extend(probes); + } + + fn cached_probe() -> Option> { + match path_cache().lock().unwrap_or_else(|e| e.into_inner()).state { + LoginShellPath::Uninit => None, + LoginShellPath::Probed(ref v) => Some(v.clone()), + } + } + + fn generation() -> u64 { + path_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .generation + } + + /// A probe that started before a refresh must not recache its stale result. + /// Models the P1 interleaving: probe A captures generation G; a forced + /// refresh bumps to G+1 and (via probe B) commits a fresh PATH; then A + /// finishes late and tries to publish. A publishes a non-empty *success* + /// (`/stale/bin`), which the same-generation `None`-over-`Some` rule would + /// accept — so only the generation guard can reject it. This keeps the test + /// non-vacuous: delete the generation comparison and stale overwrites fresh. + #[test] + fn stale_probe_cannot_commit_after_refresh() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + + // Probe A starts here. + let gen_a = generation(); + + // A forced refresh invalidates the cache; probe B (new generation) then + // commits a fresh PATH. + refresh_login_shell_path(); + let gen_b = generation(); + assert_ne!(gen_a, gen_b, "refresh must bump the generation"); + publish_probe_result(gen_b, Some("/fresh/bin".to_string())); + + // Probe A finishes late and tries to publish a *stale success* under + // its old generation. Only the generation guard can reject this — the + // same-generation success-retention rule would let a `Some` through. + publish_probe_result(gen_a, Some("/stale/bin".to_string())); + + assert_eq!( + cached_probe(), + Some(Some("/fresh/bin".to_string())), + "a pre-refresh probe must not overwrite the post-refresh fresh PATH" + ); + + // Restore the shared cache so sibling tests re-probe a real PATH rather + // than reading this fixture value. + refresh_login_shell_path(); + } + + /// Within one generation a slow failure/timeout must not clobber a peer's + /// already-committed success. Two cold callers race under generation G: the + /// success lands first, the timeout (`None`) lands second and is dropped. + #[test] + fn timeout_does_not_clobber_committed_success() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + let gen = generation(); + + // Caller 1 succeeds. + publish_probe_result(gen, Some("/usr/local/bin".to_string())); + // Caller 2 times out later in the same generation. + publish_probe_result(gen, None); + + assert_eq!( + cached_probe(), + Some(Some("/usr/local/bin".to_string())), + "a same-generation timeout must not overwrite a committed success" + ); + + // Restore the shared cache so sibling tests re-probe a real PATH rather + // than reading this fixture value. + refresh_login_shell_path(); + } + + /// P1 #2, divergence (a): same-generation timeout-vs-success. A caller + /// whose own probe times out (`None`) must still return the success a peer + /// committed under the same generation — never its own `None`, which would + /// let a forced discovery on this thread settle a PATH-missing UI while the + /// authoritative cache holds the peer's success. + /// + /// Injected probe: commit the peer's `/peer/bin` success, then return `None` + /// (this caller's timeout). Non-vacuous for the "return authoritative value" + /// rule: return the local result instead and this yields `None`. + #[test] + fn caller_returns_peer_success_not_own_timeout() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + let gen = generation(); + + inject_probes(vec![Box::new(move || { + // A peer probe finishes first and commits a success under gen. + publish_probe_result(gen, Some("/peer/bin".to_string())); + // Our probe then times out. + None + })]); + + assert_eq!( + login_shell_path(), + Some("/peer/bin".to_string()), + "a timed-out caller must return the peer's committed success, not its own None" + ); + + refresh_login_shell_path(); + } + + /// P1 #2, divergence (b): a pre-refresh probe whose writeback is + /// generation-rejected must not return its stale local value; the caller + /// re-probes under the new generation and returns the fresh result. + /// + /// First injected probe refreshes mid-flight (bumping the generation) and + /// returns a stale `/stale/bin`; publication is rejected, so the caller + /// loops and the second probe returns the fresh `/fresh/bin`. Non-vacuous + /// for the re-probe rule: return the stale local value on a rejected commit + /// instead and this yields `/stale/bin`. + #[test] + fn caller_reprobes_after_midprobe_refresh() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + + inject_probes(vec![ + Box::new(|| { + // A forced refresh lands while this probe runs, invalidating the + // generation it started under; its result is stale by definition. + refresh_login_shell_path(); + Some("/stale/bin".to_string()) + }), + Box::new(|| Some("/fresh/bin".to_string())), + ]); + + assert_eq!( + login_shell_path(), + Some("/fresh/bin".to_string()), + "a generation-rejected probe must re-probe, never return its stale local value" + ); + + refresh_login_shell_path(); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index ff5cfc3472..577a780d6c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -167,9 +167,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 +204,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), diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 5369b6321b..aab5cd4529 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -187,3 +187,30 @@ fn cheap_discovery_never_spawns_login_shell_even_when_cold() { "the forced path must probe the absent command via login shell at least once, got {forced}" ); } + +/// Regression: `resolve_command_cached` (the cheap discovery path) must find a +/// bundled sidecar sitting next to the executable via a filesystem stat, even +/// with a cold resolve cache. Before the fix it consulted only the managed-shim +/// dirs + cache, so `buzz-agent` reported "not installed" at every cold launch. +/// Here the path form exercises the same `resolve_workspace_command` stat the +/// cheap path now shares. +#[cfg(unix)] +#[test] +fn cheap_path_resolves_workspace_sidecar_without_cache() { + use crate::managed_agents::discovery::resolve_command_cached; + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("buzz-sidecar-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("buzz-agent"); + std::fs::write(&bin, "#!/bin/sh\n").expect("write sidecar"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + assert_eq!( + resolve_command_cached(bin.to_str().expect("utf8 path")), + Some(bin.clone()), + "cheap path must resolve a bundled sidecar by path with a cold cache" + ); + + let _ = std::fs::remove_dir_all(dir); +} 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 080a8fbb98..1ed44ace94 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/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 9d090787c7..5f39b7b75f 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 c005e8858b..8ff68a209f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -8,6 +8,8 @@ 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 claude_config; pub(crate) mod config_bridge; @@ -56,7 +58,8 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { 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::*; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 5f375e23c1..46f36212ce 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 c6056d4b83..c712b2525d 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 9aa1eeb098..7d54c5a7b0 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 27ee19eb67..f0806c8bc0 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 619122d916..fa80b456a0 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 ffbb575224..9367ad463e 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 3c8a40231d..094d0a1a47 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 1fd8c3bccf..a52f6aa3b1 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/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 479d6ec913..8e27ba1031 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -45,20 +45,19 @@ impl Drop for JobHandle { /// the caller can fall back to `Child::kill()` — a degraded teardown beats a /// failed spawn. /// -/// Assignment happens immediately after spawn, on the same parent thread. The -/// child (buzz-acp) does spawn its 24 workers before it connects to the relay, -/// so the window between our spawn and our assignment is NOT structurally empty. -/// What closes it is assign-latency: `OpenProcess` + `AssignProcessToJobObject` -/// are a few synchronous Win32 calls (microseconds), while buzz-acp must init -/// tokio, parse its config, and spawn 24 children (tens-to-hundreds of ms), so -/// the assign reliably wins before any worker exists. Once assigned, Windows -/// places every subsequently-spawned descendant in the job automatically. +/// For the harness spawn path ([`finish_spawn`]) assignment happens immediately +/// after a normal spawn. The child (buzz-acp) must init tokio, parse its config, +/// and spawn 24 children (tens-to-hundreds of ms) before any descendant exists, +/// so the microsecond `OpenProcess` + `AssignProcessToJobObject` reliably wins +/// that race. Once assigned, Windows places every subsequently-spawned +/// descendant in the job automatically. /// -/// `CREATE_SUSPENDED` -> assign -> `ResumeThread` would make the window airtight -/// regardless of child timing, but it requires raw `CreateProcessW`/`ResumeThread` -/// (materially more unsafe Win32) to close a microsecond race, so it is -/// deliberately not used here. -fn create_job_for_child(pid: u32) -> Option { +/// The discovery path (`bounded_command`) runs arbitrary probe commands that +/// can background a descendant and exit in the same tick, so it cannot rely on +/// assign-latency. It spawns with `CREATE_SUSPENDED`, assigns the frozen child +/// here, then calls [`resume_process`] — no descendant can exist until the job +/// owns the root, closing the race by construction. +pub(crate) fn create_job_for_child(pid: u32) -> Option { use std::ptr::null; use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; use windows_sys::Win32::System::JobObjects::{ @@ -105,6 +104,56 @@ fn create_job_for_child(pid: u32) -> Option { } } +/// Resume a process spawned with `CREATE_SUSPENDED` by resuming every thread it +/// owns. A fresh `CREATE_SUSPENDED` process has exactly one thread suspended at +/// its entry point; resuming it lets the process run. We enumerate via a +/// ToolHelp thread snapshot filtered to `pid` rather than tracking the initial +/// thread id (`std::process::Command` does not expose it), and resume each so +/// the walk is correct even in the pathological multi-thread case. +/// +/// Returns `true` only if at least one owned thread was resumed. `false` means +/// no thread could be resumed — the caller must treat the child as unusable and +/// tear it down, since a still-suspended root would otherwise hang to the +/// deadline. +pub(crate) fn resume_process(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { + return false; + } + + let mut entry: THREADENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + + let mut resumed_any = false; + let mut has_entry = Thread32First(snapshot, &mut entry); + while has_entry != 0 { + if entry.th32OwnerProcessID == pid { + let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID); + if !thread.is_null() { + // ResumeThread returns u32::MAX on failure; any other value + // is the thread's previous suspend count. + if ResumeThread(thread) != u32::MAX { + resumed_any = true; + } + CloseHandle(thread); + } + } + entry.dwSize = std::mem::size_of::() as u32; + has_entry = Thread32Next(snapshot, &mut entry); + } + + CloseHandle(snapshot); + resumed_any + } +} + /// Kill the entire process tree rooted at `pid` via `taskkill /T`, the closest /// equivalent to the Unix process-group kill. Used on the after-restart path /// where no job handle survived. `CREATE_NO_WINDOW` keeps taskkill's own diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 909b97d652..9af3c989f4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1473,9 +1473,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, 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 afaaa2b4eb..e6570482af 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -67,6 +67,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // 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 a225f492d3..881ac99237 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -454,6 +454,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, + ), }, )) }) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b21..f6bb758fbe 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -501,7 +501,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) @@ -809,6 +808,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } + crate::build_identity::apply_demo_config_home(&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 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 ec78cc14ef..05e11fc4cd 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 24fad1461c..b0c9328970 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, 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 bcd93da851..28a86aa579 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -42,6 +42,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 +104,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/team_catalog/tests.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs index e0ae5fc37a..8f9d68245d 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 32fe39531d..fdeb54c4f2 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 98816a07e3..342dc59d52 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -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 7d4b43f01d..b3c9d4b53c 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 diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 3e1afff256..a7b379ac83 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, @@ -269,6 +276,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 5299eb4ecc..0918ab2c65 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 1e22d7aaec..9b105e94d4 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_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 5bc8a6e432..2573ce2d56 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/persona_catalog.rs b/desktop/src-tauri/src/persona_catalog.rs index 5d1717d67c..c04afb64b4 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 d3175ef980..64cb1ce211 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 f408ef2afd..676b9656ff 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 4ae3924932..0fcbc891b7 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 18ddd80eb8..401d63c9c4 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 0000000000..9db2ab3dc7 --- /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/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 71db052369..fcdc29fc5f 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { startBootWarm } from "@/features/agents/acpRuntimesQuery"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { useForegroundQueryRefresh } from "@/features/workflows/hooks"; import { relayClient } from "@/shared/api/relayClient"; @@ -23,6 +25,22 @@ export function useAppShellLifecycleEffects({ useRelayResumeTriggers(); useForegroundQueryRefresh(); + // Warm the ACP runtime catalog once at app launch. The shared runtime-catalog + // cache is in-memory only, so it starts cold every boot; the cheap discovery + // path reports every harness as "(not installed)" until a forced pass warms + // it. The create/edit picker and Agents > Agent defaults surfaces read that + // cheap path, so without this warm they render all-missing (and block agent + // save) until the user visits Settings > Agents — the accidental workaround. + // `startBootWarm` drives the module-level boot-warm gate (once per launch, so + // this remounting effect never re-fires the probe) which makes those cheap + // surfaces show loading/retryable-error instead of blessing the cold catalog, + // and swallows the probe's own errors so a failure leaves the last good + // catalog in place without an unhandled rejection. + const queryClient = useQueryClient(); + React.useEffect(() => { + void startBootWarm(queryClient); + }, [queryClient]); + // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). // Composer's onDrop fires first (React synthetic before window bubble). diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164d..3506824450 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -236,7 +236,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 +281,7 @@ 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. ## The tests that enforce this @@ -289,6 +320,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/acpRuntimesQuery.test.mjs b/desktop/src/features/agents/acpRuntimesQuery.test.mjs index c51dea05b8..e9320bb6e9 100644 --- a/desktop/src/features/agents/acpRuntimesQuery.test.mjs +++ b/desktop/src/features/agents/acpRuntimesQuery.test.mjs @@ -179,12 +179,15 @@ globalThis.__TAURI_INTERNALS__ = { import React from "react"; import { createRoot } from "react-dom/client"; import { act } from "react"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query"; import { acpRuntimesQueryKey, + applyBootWarmGate, + getBootWarmSnapshot, refreshAcpRuntimes, + startBootWarm, useAcpRuntimesQueryForced, } from "./acpRuntimesQuery.ts"; import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery.ts"; @@ -230,6 +233,144 @@ afterEach(() => { discoverHandler = () => Promise.resolve([]); }); +// Runs FIRST so the process-global boot-warm gate is observed from `idle`. +// Covers Carl's ask: the cheap/forced race (a cold cheap catalog must read as +// loading, not authoritative, while the first forced pass is in flight) and the +// failure state (a failed forced pass must surface a retryable error carrying +// the real reason, not a silent empty catalog), plus recovery on retry. +describe("boot-warm gate drives cheap consumers through the initial pass", () => { + it("applyBootWarmGate: a non-empty cold catalog is not authoritative while pending or failed", () => { + // The real cold cheap response is NEVER empty: discovery always emits the + // known runtimes as not_installed/cli_missing rows plus presets. Model that + // wire shape so the gate is exercised against the payload it exists to + // gate, not a `[]` that never occurs in production. + const coldCatalog = { + data: [ + rawEntry("codex", "unknown"), + rawEntry("goose", "unknown"), + rawEntry("claude-code", "unknown"), + ], + error: null, + isLoading: false, + isPending: false, + isFetching: false, + isError: false, + }; + // A consumer maps `isLoading -> "loading"`, `isError -> "error"`, else + // `"ready"`. "Ready" is what blesses the cold rows as authoritative — the + // exact P2 defect. Assert neither pending nor failed reads as ready. + const readsAsReady = (q) => !q.isLoading && !q.isError; + + const pending = applyBootWarmGate(coldCatalog, { + status: "pending", + error: null, + }); + assert.equal(pending.isLoading, true); + assert.equal(pending.isPending, true); + assert.equal( + readsAsReady(pending), + false, + "pending must not read as ready", + ); + // The catalog rows are preserved so a consumer reading `data ?? []` keeps + // them; only the lifecycle flags are overlaid. + assert.equal(pending.data.length, 3); + + const reason = new Error("PATH probe timed out"); + const failed = applyBootWarmGate(coldCatalog, { + status: "failed", + error: reason, + }); + assert.equal(failed.isError, true); + assert.equal(failed.error, reason); + assert.equal(readsAsReady(failed), false, "failed must not read as ready"); + assert.equal(failed.data.length, 3); + + // idle/settled pass through untouched: onboarding renders before the warm + // starts (idle) and the warmed hot path (settled) must both read as ready. + for (const status of ["idle", "settled"]) { + const passed = applyBootWarmGate(coldCatalog, { status, error: null }); + assert.equal(passed.isLoading, false); + assert.equal(passed.isError, false); + assert.equal(readsAsReady(passed), true, `${status} must read as ready`); + } + }); + + it("applyBootWarmGate: a warmed non-empty catalog reads as ready once settled", () => { + const warm = { + data: [rawEntry("codex", "logged_in")], + error: null, + isLoading: false, + isPending: false, + isFetching: false, + isError: false, + }; + const settled = applyBootWarmGate(warm, { status: "settled", error: null }); + assert.equal(settled.isLoading, false); + assert.equal(settled.isError, false); + assert.equal(settled.data.length, 1); + }); + + it("applyBootWarmGate: failed reads as a retryable error with the real reason", () => { + const cold = { + data: [], + error: null, + isLoading: true, + isPending: true, + isFetching: true, + isError: false, + }; + const reason = new Error("PATH probe timed out"); + const failed = applyBootWarmGate(cold, { status: "failed", error: reason }); + assert.equal(failed.isError, true); + assert.equal(failed.error, reason); + assert.equal(failed.isLoading, false, "a failed warm is not still loading"); + }); + + it("startBootWarm: failure marks the gate failed, a retry settles it", async () => { + assert.equal( + getBootWarmSnapshot().status, + "idle", + "gate must start idle before any warm", + ); + + const queryClient = makeQueryClient(); + queryClient.mount(); + + // 1. First forced pass fails: the gate goes `failed` and captures the + // reason, so cold cheap surfaces can show a retryable error. + let failForced = true; + discoverHandler = (args) => + args?.force === true && failForced + ? Promise.reject(new Error("discovery boom")) + : Promise.resolve([]); + await startBootWarm(queryClient); + assert.equal(getBootWarmSnapshot().status, "failed"); + assert.equal(getBootWarmSnapshot().error?.message, "discovery boom"); + + // 2. A retry that succeeds settles the gate and clears the error, so cheap + // consumers stop overlaying and render the warmed catalog. + failForced = false; + discoverHandler = () => Promise.resolve([rawEntry("codex", "logged_in")]); + await startBootWarm(queryClient); + assert.equal(getBootWarmSnapshot().status, "settled"); + assert.equal(getBootWarmSnapshot().error, null); + + // 3. Once settled, further boot warms are no-ops (fixes the per-remount + // re-fire): no additional forced probe fires. + const before = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + await startBootWarm(queryClient); + const after = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + assert.equal(after, before, "a settled gate must not re-fire the probe"); + + queryClient.unmount(); + }); +}); + describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () => { it("runs a distinct force:true probe and writes it into the shared cache", async () => { const queryClient = makeQueryClient(); @@ -275,6 +416,59 @@ describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () = queryClient.unmount(); }); + + it("an in-flight cheap query cannot clobber the forced result after refresh", async () => { + // Carl's settle-order finding: a cheap query in flight on the shared key + // must not land its (older) result after the forced catalog is written. + // `refreshAcpRuntimes` cancels the shared-key query before settling; this + // proves the cancel is load-bearing by holding a real cheap observer + // fetching, running the forced refresh, then resolving the cheap request + // late — its result must not overwrite the forced catalog, and the gate + // must settle on the forced state. (Removing the `cancelQueries` call makes + // the late cheap result win and fails this test.) + const queryClient = makeQueryClient(); + queryClient.mount(); + + // Seed a pre-existing cold catalog, then start a mounted cheap observer that + // refetches and is held pending — the real in-flight shape. + queryClient.setQueryData(acpRuntimesQueryKey, [ + rawEntry("codex", "unknown"), + ]); + const cheap = deferred(); + discoverHandler = (args) => { + if (args?.force === false) return cheap.promise; + return Promise.resolve([rawEntry("codex", "logged_in")]); + }; + const observer = new QueryObserver(queryClient, { + queryKey: acpRuntimesQueryKey, + queryFn: () => discoverAcpRuntimes(), + staleTime: 0, + }); + const unsubscribe = observer.subscribe(() => {}); + await new Promise((r) => setImmediate(r)); + + // Forced refresh completes and settles while the cheap observer is fetching. + await refreshAcpRuntimes(queryClient); + + // The cheap request resolves afterward; its result must be dropped. + cheap.resolve([rawEntry("codex", "unknown")]); + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + assert.equal( + queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status, + "logged_in", + "shared cache must remain the forced result after a late cheap resolution", + ); + assert.equal( + getBootWarmSnapshot().status, + "settled", + "the gate must settle on the forced catalog, not the stale cheap state", + ); + + unsubscribe(); + queryClient.unmount(); + }); }); describe("useAcpRuntimesQueryForced surfaces forced-probe failures", () => { diff --git a/desktop/src/features/agents/acpRuntimesQuery.ts b/desktop/src/features/agents/acpRuntimesQuery.ts index 0e76e25ee7..16f82a0aa9 100644 --- a/desktop/src/features/agents/acpRuntimesQuery.ts +++ b/desktop/src/features/agents/acpRuntimesQuery.ts @@ -19,6 +19,153 @@ export const acpRuntimesQueryKey = ["acp-runtimes"] as const; */ export const acpRuntimesForcedQueryKey = ["acp-runtimes", "forced"] as const; +/** + * Boot-warm gate for the *initial* forced discovery pass. + * + * The shared runtime catalog is in-memory only, so it starts cold every launch: + * the cheap discovery path reports every harness `(not installed)` until a + * forced pass warms it. Without a gate, the create/edit picker and Agents > + * Agent defaults surfaces read that cheap path and present the cold catalog as + * *authoritative* — blessing every harness as unavailable and blocking save — + * during the 20–65s boot probe, and forever if that probe fails. + * + * This module-level state lets cheap consumers (`useAcpRuntimesQuery`) treat the + * catalog as still-loading while the first forced pass is in flight and as a + * retryable error if it failed, instead of authoritative. It is process-global + * (one launch), so `startBootWarm` runs the warm exactly once no matter how many + * times `AppShell` mounts — that also fixes the per-remount re-fire. + * + * The seam that protects onboarding (which renders before `AppShell` fires the + * warm): the gate only overlays loading/error once the warm has *started* + * (`pending`/`failed`). While `idle` — no warm yet, e.g. the onboarding flow — + * cheap consumers behave exactly as before. A successful forced refresh from any + * surface settles the gate, so onboarding's own forced warm clears it too. + */ +export type AcpBootWarmStatus = "idle" | "pending" | "settled" | "failed"; + +/** + * A stable snapshot object for `useSyncExternalStore`: `getSnapshot` must return + * a referentially-stable value between changes, so the object is rebuilt only in + * `setBootWarm`, never per read. + */ +let bootWarmSnapshot: { status: AcpBootWarmStatus; error: Error | null } = { + status: "idle", + error: null, +}; +const bootWarmListeners = new Set<() => void>(); + +function setBootWarm(status: AcpBootWarmStatus, error: Error | null) { + if (bootWarmSnapshot.status === status && bootWarmSnapshot.error === error) { + return; + } + bootWarmSnapshot = { status, error }; + for (const listener of bootWarmListeners) listener(); +} + +export function subscribeBootWarm(listener: () => void) { + bootWarmListeners.add(listener); + return () => { + bootWarmListeners.delete(listener); + }; +} + +export function getBootWarmSnapshot() { + return bootWarmSnapshot; +} + +/** + * Overlay the launch boot-warm gate onto a cheap-path query result so cheap + * consumers never present a cold catalog as authoritative. Pure so it can be + * unit-tested without a mounted hook. + * + * The cheap backend response is *never* empty on a cold cache — discovery + * always emits the full set of known runtimes (as `not_installed`/`cli_missing` + * rows) plus presets. Gating on `data.length` would therefore be a no-op for the + * exact payload this exists to gate, so the gate keys on the boot-warm state + * instead and always preserves `query.data`: + * + * - `pending` (first forced pass in flight) reads as loading, so a cold catalog + * is presented as still-loading rather than a settled "everything + * unavailable" list — even though those cold rows are non-empty. + * - `failed` (forced pass rejected) reads as a retryable error carrying the + * probe's real reason. + * - `idle`/`settled` pass the query through unchanged, so onboarding (which + * renders before the warm starts) and the warmed hot path are untouched. + * + * `query.data` is preserved on every branch: overlaying only the lifecycle + * flags means a consumer that reads `data ?? []` keeps its rows while a + * status-driven consumer correctly treats them as not-yet-authoritative. + */ +export function applyBootWarmGate< + Q extends { + data?: unknown[]; + error: Error | null; + isLoading: boolean; + isPending: boolean; + isFetching: boolean; + isError: boolean; + }, +>(query: Q, bootWarm: { status: AcpBootWarmStatus; error: Error | null }): Q { + if (bootWarm.status === "pending") { + return { ...query, isLoading: true, isPending: true, isFetching: true }; + } + if (bootWarm.status === "failed") { + return { + ...query, + isError: true, + error: bootWarm.error ?? query.error, + isLoading: false, + }; + } + return query; +} + +/** + * Run the initial forced discovery pass once per launch and drive the boot-warm + * gate. `AppShell` calls this on mount; the `pending`/`settled` short-circuit + * makes remounts no-ops (fixing the re-fire) while still retrying after a prior + * failure. Success is recorded by `refreshAcpRuntimes` itself (any forced + * success settles the gate); this only has to mark its own failure. + */ +export async function startBootWarm( + queryClient: ReturnType, +) { + const status: AcpBootWarmStatus = bootWarmSnapshot.status; + if (status === "pending" || status === "settled") { + return; + } + setBootWarm("pending", null); + const result = await refreshAcpRuntimes(queryClient); + // A concurrent forced success may have already settled the gate; only mark + // failed if this pass is still the pending one and it returned no catalog. + if (result === undefined && bootWarmSnapshot.status === "pending") { + setBootWarm("failed", lastForcedError); + } +} + +/** + * A stable callback that re-runs the boot warm after it failed, for the retry + * affordance the cheap-path surfaces (create/edit picker, Agent defaults) show + * when the gate is in its `failed` state. `startBootWarm` is the retry + * primitive: from `failed` it transitions back through `pending` (so the + * surface shows loading again) to `settled` on success or `failed` with a fresh + * reason on another rejection. It no-ops while `pending`/`settled`, so a + * double-click cannot stack probes. + */ +export function useRetryBootWarm() { + const queryClient = useQueryClient(); + return React.useCallback(() => { + void startBootWarm(queryClient); + }, [queryClient]); +} + +/** + * The error from the most recent failed forced probe, surfaced through the + * boot-warm `failed` state so a cold catalog shows a real reason rather than a + * silent empty list. Cleared on the next forced success. + */ +let lastForcedError: Error | null = null; + /** * Run a forced (full re-discovery) refresh and write the result into the shared * runtime-catalog cache. @@ -48,13 +195,20 @@ export async function refreshAcpRuntimes( staleTime: 0, gcTime: 0, }); - queryClient.setQueryData(acpRuntimesQueryKey, result); - // A hot-surface cheap fetch may already be in flight on the shared key; cancel - // it so its (older, cached) result cannot land after and clobber the fresh - // forced catalog we just wrote. + // Cancel and *await* the in-flight cheap query on the shared key BEFORE + // writing the forced result. `cancelQueries` defaults to `revert: true`, so + // cancellation restores the cheap query's pre-fetch state; doing it after + // `setQueryData` would let that revert land last and clobber the fresh + // forced catalog, and the gate would then settle on the stale state. With + // the cancel awaited first, our `setQueryData` is the final write. await queryClient.cancelQueries({ queryKey: acpRuntimesQueryKey }); + queryClient.setQueryData(acpRuntimesQueryKey, result); + // Any forced success proves the catalog is warm: settle the boot-warm gate + // and clear the last error, so cheap consumers stop overlaying loading/error. + lastForcedError = null; + setBootWarm("settled", null); return result; - } catch { + } catch (error) { // The forced probe rejected. `fetchQuery` has already recorded the error in // the forced key's query state, where `useAcpRuntimesQueryForced` projects // it into the hook's returned `error`/`isError`. Swallow the rejection here @@ -63,7 +217,9 @@ export async function refreshAcpRuntimes( // paths) can keep `void refreshAcpRuntimes(...)` without ever leaking an // unhandled rejection, and a new call site can never reintroduce one. The // shared cache is left untouched so consumers keep the last good catalog - // alongside the surfaced error. + // alongside the surfaced error. Record the error so a failed boot warm can + // surface a real reason on the cheap-path surfaces (via the boot-warm gate). + lastForcedError = error instanceof Error ? error : new Error(String(error)); return undefined; } } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index b7dd766733..3daf4fa78c 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -52,9 +52,15 @@ import { import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks"; import { acpRuntimesQueryKey, + applyBootWarmGate, + getBootWarmSnapshot, refreshAcpRuntimes, + subscribeBootWarm, +} from "@/features/agents/acpRuntimesQuery"; +export { + useAcpRuntimesQueryForced, + useRetryBootWarm, } from "@/features/agents/acpRuntimesQuery"; -export { useAcpRuntimesQueryForced } from "@/features/agents/acpRuntimesQuery"; import { createPersona, deletePersona, @@ -218,12 +224,23 @@ function invalidateManagedAgentQueriesInBackground( * probe pipeline. */ export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { - return useQuery({ + const query = useQuery({ enabled: options?.enabled ?? true, queryKey: acpRuntimesQueryKey, queryFn: () => discoverAcpRuntimes(), staleTime: 30 * 60_000, }); + // Overlay the launch boot-warm gate so cheap consumers never present a cold + // catalog as authoritative: until the first forced pass settles, an un-warmed + // catalog reads as loading (`pending`) or a retryable error (`failed`) rather + // than "every harness not installed". `applyBootWarmGate` preserves an + // already-good list and passes through untouched while idle/settled. + const bootWarm = React.useSyncExternalStore( + subscribeBootWarm, + getBootWarmSnapshot, + getBootWarmSnapshot, + ); + return applyBootWarmGate(query, bootWarm); } export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) { diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 21880eca2f..5398b28f05 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -8,6 +8,7 @@ import { getAgentMentionAdmission, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentIdentityInAllowedList, isAgentMentionChannelType, relayAgentCanRespondInChannel, @@ -42,6 +43,15 @@ function makeAgent(overrides = {}) { }; } +test("isAgentDirectoryReady: requires successful cached directory evidence", () => { + assert.equal(isAgentDirectoryReady({ data: [], error: null }), true); + assert.equal(isAgentDirectoryReady({ data: undefined, error: null }), false); + assert.equal( + isAgentDirectoryReady({ data: [], error: new Error("offline") }), + false, + ); +}); + test("getSharedChannelIds: includes only active joined channels", () => { assert.deepEqual( getSharedChannelIds([ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 4e1c787f92..e3c82cfff4 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,6 +1,19 @@ import type { Channel, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function isAgentDirectoryReady({ + data, + error, +}: { + data: unknown; + error: unknown; +}) { + // A successful cached directory remains suitable for autocomplete during a + // refetch. Sending still re-fetches and fails closed at its authorization + // boundary, so suggestions are hints rather than permission to send. + return data !== undefined && error === null; +} + export function getSharedChannelIds(channels: readonly Channel[] | undefined) { return new Set( (channels ?? []) 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 0000000000..52e9d65a53 --- /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 0000000000..7a1b8ae2c0 --- /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/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 63a357e448..928920f9a3 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/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 99d3167d00..dcb8386497 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -191,6 +191,7 @@ export function AddTeamToChannelDialog({ avatarUrl={persona.avatarUrl} className="h-5 w-5 text-2xs" label={persona.displayName} + shape="squircle" /> {persona.displayName} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 295c37f23c..52a58c9134 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -818,7 +818,6 @@ export function AgentConfigFields({ fallbackModel === null && !dependentFieldsDisabled } - keepSelectedModelValueLabel model={dependentFieldsDisabled ? "" : (config.model ?? "")} modelDiscoveryLoading={ dependentFieldsDisabled ? false : modelDiscoveryLoading diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index f46321093e..106954879e 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -149,7 +149,6 @@ export function AgentCreationPreview({ }, processImage, }); - useEmojiMartStyles( emojiPickerContainerRef, isAvatarMenuOpen && activeTab === "emoji", @@ -157,7 +156,6 @@ export function AgentCreationPreview({ isOnboarding ? "light" : null, isOnboarding, ); - // Emoji Mart mounts its search input inside a shadow root. Wait for it // before focusing so the surrounding Radix popover cannot win the race. React.useEffect(() => { @@ -670,7 +668,7 @@ export function AgentCreationPreview({ ? isCompact ? "rounded-2xl" : "rounded-[2rem]" - : "rounded-full", + : "rounded-[30%]", )} role="img" style={{ backgroundColor: emojiAvatarPreview.color }} @@ -696,6 +694,7 @@ export function AgentCreationPreview({ ) : ( ) : configSurfaceError ? ( -
- - Couldn't load agent defaults. Restart the app to try again. -
+ runtimeCatalogError && !loadError ? ( +
+ +
+ ) : ( +
+ + Couldn't load agent defaults. Restart the app to try again. +
+ ) ) : ( <>
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 06f41667b0..c0968ad0e9 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -11,6 +11,7 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { AgentCreationPreview } from "./AgentCreationPreview"; +import { AgentIdentityFields } from "./AgentDescriptionField"; import { PersonaDropdownField } from "./PersonaDropdownField"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { PersonaAdvancedFields } from "./PersonaAdvancedFields"; @@ -139,6 +140,7 @@ export function AgentDefinitionDialog({ }: AgentDefinitionDialogProps) { const runtimesLoading = runtimeCatalogStatus === "loading"; const [displayName, setDisplayName] = React.useState(""); + const [descriptionDraft, setDescriptionDraft] = React.useState(""); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); const [avatarUrl, setAvatarUrl] = React.useState(""); @@ -205,6 +207,7 @@ export function AgentDefinitionDialog({ } setDisplayName(initialValues.displayName); + setDescriptionDraft(initialValues.description ?? ""); setAvatarUrl(initialValues.avatarUrl ?? ""); setSystemPrompt(initialValues.systemPrompt); setRuntime(initialValues.runtime ?? ""); @@ -357,6 +360,8 @@ export function AgentDefinitionDialog({ : undefined; const baseInput = { displayName: displayName.trim(), + // Empty string → null happens in the API wrapper (normalizeDescription). + description: descriptionDraft, avatarUrl: avatarUrl.trim() || undefined, systemPrompt: systemPrompt, runtime: runtimeForSubmit, @@ -759,33 +764,13 @@ export function AgentDefinitionDialog({ />
-
- -
- setDisplayName(event.target.value)} - placeholder="Fizz" - value={displayName} - /> -
-
+
+ {description ? ( +

+ {description} +

+ ) : null} + + + Couldn't detect agent harnesses. + +
+ ); +} diff --git a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx index 0381ebc03b..7b09ca5caf 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 (
diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 695504429d..1796adaa6b 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 8e4b02c9e8..45931d143a 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 d0ff2e2738..36f950d4c5 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -6,6 +6,7 @@ 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 { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; @@ -253,12 +254,16 @@ function AgentPersonaCard({ onStartPersona: (persona: AgentPersona) => void; }) { 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 @@ -312,7 +317,7 @@ function AgentPersonaCard({ avatarUrl={avatarUrl} dataTestId={`persona-agent-row-${persona.id}`} 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 @@ -394,12 +399,16 @@ function StandaloneAgentCard({ avatarUrl={profileQuery.data?.avatarUrl} dataTestId={`managed-agent-${agent.pubkey}`} 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, diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 181e4febf5..470dd1b894 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 1a431d1f91..677db669a3 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/modelCapabilitiesCorpus.test.mjs b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs index 8d75b71d49..492c1cd6ac 100644 --- a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs +++ b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs @@ -25,10 +25,10 @@ const corpus = JSON.parse(readFileSync(fileURLToPath(corpusUrl), "utf8")); // (`_group`) are skipped. Mirrors the Rust corpus filter. const executable = corpus.filter((entry) => entry.expect != null); -test("corpus has exactly 135 executable vectors", () => { +test("corpus has exactly 139 executable vectors", () => { // Locks the vector count so a silent corpus edit can't quietly drop coverage; // must equal the gate in the Rust suite (model_capabilities.rs). - assert.equal(executable.length, 135); + assert.equal(executable.length, 139); }); test("registry label aliases refuse an unprefixed query", () => { @@ -50,6 +50,9 @@ test("UC model-family FQNs and goose- aliases humanize onto their base records", // must resolve onto the same base databricks_v2 records via the new family // tokens. Mirrors the Rust `test_databricks_registry_label_lookup` coverage. const cases = [ + ["goose-claude-4-6-sonnet", "Claude Sonnet 4.6"], + ["goose-claude-4-7-opus", "Claude Opus 4.7"], + ["goose-kimi-2-7", "Kimi 2.7"], ["system.ai.gemini-3-5-flash", "Gemini 3.5 Flash"], ["system.ai.gemini-3-pro-image", "Gemini 3 Pro Image"], ["system.ai.deepseek-v4-pro-0813", "DeepSeek V4 Pro"], @@ -65,6 +68,7 @@ test("UC model-family FQNs and goose- aliases humanize onto their base records", "data_workflow_tools.goose.goose-deepseek-v4-flash-0731", "DeepSeek V4 Flash", ], + ["data_workflow_tools.goose.goose-glm-5-3", "GLM-5.3"], ["data_workflow_tools.goose.goose-glm-5-3-flash", "GLM-5.3 Flash"], ["data_workflow_tools.goose.goose-grok-4-6", "Grok 4.6"], ]; diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index b786bf5573..aab59803dd 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -75,6 +75,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => id: "persona-1", displayName: "Solo", avatarUrl: "avatar://solo", + description: "Reviews desktop changes.", systemPrompt: "Be direct.", runtime: "provider-a", model: "model-a", @@ -88,6 +89,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => assert.deepEqual(state.initialValues, { displayName: "Solo copy", avatarUrl: "avatar://solo", + description: "Reviews desktop changes.", systemPrompt: "Be direct.", runtime: "provider-a", model: "model-a", @@ -128,6 +130,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { id: "persona-2", displayName: "Kit", avatarUrl: null, + description: "Finds unusual solutions.", systemPrompt: "Keep it weird.", runtime: null, model: null, @@ -145,6 +148,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { id: "persona-2", displayName: "Kit", avatarUrl: "", + description: "Finds unusual solutions.", systemPrompt: "Keep it weird.", runtime: undefined, model: undefined, diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index e09e647b9f..a686dbd682 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -63,6 +63,7 @@ export function duplicatePersonaDialogState( initialValues: { displayName: `${persona.displayName} copy`, avatarUrl: persona.avatarUrl ?? "", + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, @@ -121,6 +122,7 @@ export function editPersonaDialogState( id: persona.id, displayName: persona.displayName, avatarUrl: persona.avatarUrl ?? "", + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 268d336eaa..7948c474c9 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -319,6 +319,7 @@ export function usePersonaActions() { updatedPersona = await createPersonaMutation.mutateAsync({ displayName: persona.displayName, avatarUrl: persona.avatarUrl ?? undefined, + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/features/channels/lib/dmParticipantDisplay.ts b/desktop/src/features/channels/lib/dmParticipantDisplay.ts index 25f8bd8993..2dd9cfda23 100644 --- a/desktop/src/features/channels/lib/dmParticipantDisplay.ts +++ b/desktop/src/features/channels/lib/dmParticipantDisplay.ts @@ -14,6 +14,7 @@ export type DmParticipantDisplay = { export type DirectMessageIntroParticipant = { avatarUrl: string | null; displayName: string; + isAgent?: boolean; pubkey: string; }; @@ -95,6 +96,7 @@ export function buildDirectMessageIntro({ profiles, pubkey: participant.pubkey, }), + ...(profile?.isAgent === true ? { isAgent: true } : {}), pubkey: participant.pubkey, }; }); diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index e27953d646..d9ad164bcd 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -38,6 +38,7 @@ function AgentRow({ className="h-9 w-9 shrink-0 text-xs" iconClassName="h-5 w-5" label={persona.displayName} + shape="squircle" /> {persona.displayName} diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 326866cf63..fa4c3fe842 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -161,6 +161,7 @@ export function AddChannelBotTeamsSection({ avatarUrl={persona.avatarUrl} className="h-4 w-4 text-3xs bg-secondary-foreground/20 text-secondary-foreground" label={persona.displayName} + shape="squircle" testId="team-tooltip-persona-avatar" /> diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx index 4990168b2f..b059debfc6 100644 --- a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -46,6 +46,7 @@ export function AddMemberSearchResultRow({ avatarUrl={user.avatarUrl} className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none" displayName={formatAddCandidateName(user)} + shape={user.isAgent ? "squircle" : "circle"} size="sm" />
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index c1933f14bb..603d15dfba 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -422,6 +422,7 @@ export function AgentSessionThreadPanel({ avatarUrl={agentProfile?.avatarUrl ?? null} className="size-9" label={agentLabel} + shape="squircle" testId="agent-session-agent-avatar" />
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index cfa84f02e3..d43b621c24 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -191,6 +191,7 @@ export function BotActivityComposerAction({ isInline ? "!h-4.5 !w-4.5 text-3xs" : "shrink-0", )} displayName={agent.name} + shape="squircle" fallbackDelayMs={isInline ? 0 : undefined} key={agent.pubkey} size="xs" @@ -259,6 +260,7 @@ export function BotActivityComposerAction({ avatarUrl={agentAvatarUrl(agent)} className="shrink-0" displayName={agent.name} + shape="squircle" size="sm" /> {agent.name} diff --git a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx index a9662bbf04..34c226a397 100644 --- a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx @@ -56,6 +56,7 @@ export function ChannelMemberAvatarStack({ className="!h-8 !w-8 border-2 border-background text-2xs" displayName={label} fallbackDelayMs={0} + shape={profile?.isAgent ? "squircle" : "circle"} /> ); diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index dd370e8c61..90fb5bfaa9 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -206,6 +206,7 @@ export function ChannelMemberInviteCard({ @@ -297,6 +298,7 @@ export function ChannelMemberInviteCard({

diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 4dc0f0f247..a93eed6837 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -1,6 +1,7 @@ import { getChannelDetail } from "@/features/channels/lib/channelDescription"; import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; import type { TimelineMessage } from "@/features/messages/types"; +import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; @@ -106,3 +107,19 @@ export function mentionsKnownAgent( knownAgentPubkeys.has(pubkey.toLowerCase()), ); } + +export function selectThreadComposerBotTypingPubkeys( + entries: TypingIndicatorEntry[], + threadHeadId: string | null, +) { + if (!threadHeadId) return []; + return entries + .filter((entry) => entry.threadHeadId === threadHeadId) + .map((entry) => entry.pubkey) + .filter( + (pubkey, index, all) => + all.findIndex( + (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), + ) === index, + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 38ebd9234a..d53e8dbbda 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -52,6 +52,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent, + selectThreadComposerBotTypingPubkeys, shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "@/features/channels/ui/ChannelPane.helpers"; @@ -103,7 +104,9 @@ export const ChannelPane = React.memo(function ChannelPane({ isJoining = false, isSinglePanelView = false, isSending, + isTimelineError = false, isTimelineLoading, + onRetryTimeline, entranceMessageId = null, onEntranceMessageComplete, welcomeKickoffStage = null, @@ -334,18 +337,11 @@ export const ChannelPane = React.memo(function ChannelPane({ const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; - const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) return []; - return botTypingEntries - .filter((entry) => entry.threadHeadId === openThreadHeadId) - .map((entry) => entry.pubkey) - .filter( - (pubkey, index, all) => - all.findIndex( - (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), - ) === index, - ); - }, [botTypingEntries, openThreadHeadId]); + const threadComposerBotTypingPubkeys = React.useMemo( + () => + selectThreadComposerBotTypingPubkeys(botTypingEntries, openThreadHeadId), + [botTypingEntries, openThreadHeadId], + ); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; const directMessageIntro = React.useMemo( @@ -662,7 +658,9 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No messages yet" : "No channel selected" } + isError={isTimelineError} isLoading={isHuddleTranscript ? false : isTimelineLoading} + onRetry={onRetryTimeline} entranceMessageId={entranceMessageId} onEntranceMessageComplete={onEntranceMessageComplete} mainEntries={mainTimelineEntries} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5bce6a71ff..1fe5bf751b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -59,7 +59,10 @@ export type ChannelPaneProps = { isJoining?: boolean; isSinglePanelView?: boolean; isSending: boolean; + /** Terminal channel-history failure. Cached messages remain visible when present. */ + isTimelineError?: boolean; isTimelineLoading: boolean; + onRetryTimeline?: () => void; /** Newly-created message that should receive the one-shot conversation arrival motion. */ entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; diff --git a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx index eb7ee4739c..8914af2933 100644 --- a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Globe, Lock } from "lucide-react"; import type { ChannelVisibility } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -10,17 +10,25 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { cn } from "@/shared/lib/cn"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; + +const VISIBILITY_OPTIONS = [ + { value: "private", label: "Private", Icon: Lock }, + { value: "open", label: "Public", Icon: Globe }, +] as const; export function ChannelPermissionsSettings({ disabled, onVisibilityChange, testIdPrefix, visibility, + variant = "dropdown", }: { disabled?: boolean; onVisibilityChange: (visibility: ChannelVisibility) => void; testIdPrefix: string; visibility: ChannelVisibility; + variant?: "dropdown" | "segmented"; }) { const visibilityLabel = visibility === "private" ? "Private" : "Public"; @@ -28,57 +36,76 @@ export function ChannelPermissionsSettings({

- Visibility - - - - - event.preventDefault()} - style={{ - minWidth: "var(--radix-dropdown-menu-trigger-width)", - }} - > - - onVisibilityChange( - nextVisibility === "private" ? "private" : "open", - ) - } - value={visibility} - > - + Visibility + + {variant === "segmented" ? ( + + ) : ( + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + onVisibilityChange( + nextVisibility === "private" ? "private" : "open", + ) + } + value={visibility} > - Private - - - - + + Public + + + Private + + + + + )}
); } diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9c2d25dd7c..f7a5b48012 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -50,10 +50,7 @@ import { isThreadReply, } from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; -import { - resolveTimelineLoadingLatch, - selectTimelineLoadingState, -} from "@/features/messages/lib/timelineLoadingState"; +import { resolveTimelineQueryLoadingState } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; @@ -616,27 +613,21 @@ export function ChannelScreen({ setThreadScrollTargetId, }); const settledChannelIdRef = React.useRef(null); - const hasSettledThisChannel = - activeChannelId !== null && settledChannelIdRef.current === activeChannelId; - const timelineLoadingNow = - activeChannel !== null && - activeChannel.channelType !== "forum" && - selectTimelineLoadingState( + const { settledChannelId, isLoading: isTimelineLoading } = + resolveTimelineQueryLoadingState( + settledChannelIdRef.current, + activeChannelId, { + isEnabled: + activeChannel !== null && activeChannel.channelType !== "forum", isPending: messagesQuery.isPending, isFetching: messagesQuery.isFetching, isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, + isError: messagesQuery.isError, }, - hasSettledThisChannel || - (activeChannelId !== null && - hasPersistedHydratedChannel(queryClient, activeChannelId)), - ); - const { settledChannelId, isLoading: isTimelineLoading } = - resolveTimelineLoadingLatch( - settledChannelIdRef.current, - activeChannelId, - timelineLoadingNow, + activeChannelId !== null && + hasPersistedHydratedChannel(queryClient, activeChannelId), ); settledChannelIdRef.current = settledChannelId; const { welcomeKickoffStage, welcomeKickoffSettingUp } = @@ -890,8 +881,8 @@ export function ChannelScreen({ isFollowingThread={isNotifiedForEffectiveThread} isSending={sendMessageMutation.isPending} isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} + isTimelineError={messagesQuery.isError} isTimelineLoading={isTimelineLoading} + onRetryTimeline={() => void messagesQuery.refetch()} messages={timelineMessages} threadSummaries={threadSummaries} huddleThreadRepliesError={huddleThreadRepliesError} onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 44e4d891dc..3ccabdf753 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -153,6 +153,7 @@ export function ChannelScreenHeader({ ) : activeDmParticipant ? ( @@ -163,6 +164,7 @@ export function ChannelScreenHeader({ geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY} iconClassName="h-4 w-4" label={activeChannelTitle} + shape={activeDmParticipant.isAgent ? "squircle" : "circle"} size={DM_HEADER_AVATAR_SIZE} status={activeDmPresenceStatus ?? "offline"} statusTestId="chat-presence-badge" @@ -177,6 +179,7 @@ export function ChannelScreenHeader({ geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY} iconClassName="h-4 w-4" label={activeChannelTitle} + shape="circle" size={DM_HEADER_AVATAR_SIZE} status={activeDmPresenceStatus ?? "offline"} statusTestId="chat-presence-badge" @@ -222,23 +225,23 @@ function DmHeaderParticipantStack({ pubkey={participant.pubkey} triggerAriaLabel={`Open profile for ${participant.displayName}`} triggerElement="span" + role={participant.isAgent ? "bot" : undefined} > 0 ? "-ml-2" : ""} data-testid="chat-header-dm-avatar-stack-participant" - style={{ - zIndex: index + 1, - ...(index < stackItemCount - 1 && { - mask: "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)", - WebkitMask: - "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)", - }), - }} + style={{ zIndex: index + 1 }} > diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 82fd1c9f7a..3f7a2d907e 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, ClockFading, Hash } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { @@ -11,6 +11,7 @@ import { } from "@/features/channels/lib/ephemeralChannel"; import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -19,9 +20,15 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; import { EditableInfoFieldRow } from "./ChannelManagementSheetRows"; import { ChannelTypePicker } from "./ChannelTypePicker"; +const CHANNEL_TYPE_OPTIONS = [ + { value: "temporary", label: "Temporary", Icon: ClockFading }, + { value: "ongoing", label: "Ongoing", Icon: Hash }, +] as const; + const EPHEMERAL_TIMEOUT_OPTIONS = [ { label: "30 minutes", seconds: 30 * 60 }, { label: "1 hour", seconds: 60 * 60 }, @@ -76,6 +83,7 @@ export function ChannelTypeSettings({ temporary, testIdPrefix, ttlSeconds, + variant = "dropdown", }: { channelId?: string | null; disabled?: boolean; @@ -87,6 +95,7 @@ export function ChannelTypeSettings({ temporary: boolean; testIdPrefix: string; ttlSeconds: number; + variant?: "dropdown" | "segmented"; }) { const projectHome = useIsProjectHomeChannel(channelId); const lifecycle = channelLifecycle({ projectHome, temporary }); @@ -116,18 +125,39 @@ export function ChannelTypeSettings({ className="flex items-center justify-between gap-3 px-3 py-3" data-testid={`${testIdPrefix}-channel-type-row`} > - {label} - onTemporaryChange(next === "temporary")} - onOpenChange={onOpenChange} - open={open} - testId={`${testIdPrefix}-channel-type`} - /> + + {label} + + {variant === "segmented" ? ( + onTemporaryChange(value === "temporary")} + optionTestIdPrefix={`${testIdPrefix}-channel-type-option`} + options={CHANNEL_TYPE_OPTIONS} + testId={`${testIdPrefix}-channel-type`} + value={temporary ? "temporary" : "ongoing"} + /> + ) : ( + + onTemporaryChange(next === "temporary") + } + onOpenChange={onOpenChange} + open={open} + testId={`${testIdPrefix}-channel-type`} + /> + )}
{temporary && !projectHome ? ( @@ -144,7 +174,10 @@ export function ChannelTypeSettings({ data-testid={`${testIdPrefix}-ephemeral-settings`} >