diff --git a/.env.example b/.env.example index db5a7ea25c7..f0e27c97e42 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ REDIS_URL=redis://localhost:6379 # Max connections in the relay's shared Redis pool (default 16). # BUZZ_REDIS_POOL_SIZE=16 +# Max connections in each of the relay's Postgres pools — writer and, when +# READ_DATABASE_URL is set, reader (default 50). +# BUZZ_DB_POOL_SIZE=50 + # ----------------------------------------------------------------------------- # Typesense (search) # ----------------------------------------------------------------------------- @@ -47,6 +51,11 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 +# Optional URL path prefix to serve the relay under. Empty (the default) mounts +# everything at /. Set this only when a gateway routes to the relay by path +# instead of by hostname — the WebSocket then answers on ws://host/ and +# every HTTP route lives under it. RELAY_URL must include the same prefix. +# BUZZ_BASE_PATH=/relay # Stable relay signing key. Set this in dev if you want REST-created forum posts # to keep resolving to the original author across relay restarts. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> @@ -105,7 +114,10 @@ RELAY_URL=ws://localhost:3000 # ----------------------------------------------------------------------------- # Logging / Tracing # ----------------------------------------------------------------------------- -RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug +RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug +# Optional OpenTelemetry-only target filter. This is deliberately independent +# from RUST_LOG so log verbosity changes cannot break trace parentage. +# BUZZ_OTEL_FILTER=buzz_relay=info,buzz_datastore=info # OTLP tracing endpoint (optional — leave unset to disable) # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..f36b1615d06 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Git for Windows defaults to core.autocrlf=true, so without this every text +# file lands in the working copy with CRLF. Biome formats with LF +# (biome.json sets no lineEnding override), which fails `biome check` on +# effectively every file, and +# desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs asserts on +# patches/*.patch with `\n`-joined patterns. Normalize to LF in the working +# copy on every platform; the stored blobs are already LF. +* text=auto eol=lf diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 325a485102a..238878c7493 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,4 +5,4 @@ ### Testing - + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acdf5237bd8..d4826d985f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,15 +48,21 @@ jobs: - 'scripts/run-tests.sh' - 'justfile' desktop: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'desktop/**' - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' desktop-rust: - 'desktop/src-tauri/**' web: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'web/**' - 'pnpm-lock.yaml' mobile: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'mobile/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' @@ -76,6 +82,8 @@ jobs: scripts/test-mobile-release-candidate-publisher.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh + - name: File size ratchet unit tests + run: node --test scripts/check-file-sizes-core.test.mjs rust-lint: name: Rust Lint @@ -130,6 +138,8 @@ jobs: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -213,7 +223,7 @@ jobs: desktop-smoke-e2e: name: Desktop Smoke E2E (${{ matrix.shard }}) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' strategy: @@ -340,6 +350,7 @@ jobs: cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ --cargo-profile ci \ + -p buzz-db \ -p buzz-relay \ -p buzz-test-client \ --lib \ @@ -671,11 +682,11 @@ jobs: done cat /tmp/buzz-relay.log exit 1 - - name: Invite claim security tests + - name: Invite security tests run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(claim_)' \ + -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz @@ -750,6 +761,8 @@ jobs: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Get pnpm store directory id: pnpm-cache @@ -783,6 +796,8 @@ jobs: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Compute Hermit cache key id: hermit-bin-hash @@ -821,6 +836,8 @@ jobs: with: path: ~/.pub-cache key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + - name: File size ratchet + run: node mobile/scripts/check-file-sizes.mjs - name: Format check run: cd mobile && dart format --output=none --set-exit-if-changed . - name: Analyze diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 31080652eaf..52f21b28bc3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,8 @@ name: Docker image -# Builds and publishes the public Buzz relay image as ghcr.io/block/buzz. +# Builds and publishes the public Buzz relay images as ghcr.io/block/buzz. +# Normal tags contain stripped binaries; matching debug-* tags contain the same +# optimized binaries with line-table debug information for native profilers. # # Strategy: each architecture builds on its native runner (ubuntu-24.04 for # amd64, ubuntu-24.04-arm for arm64), pushes to GHCR by digest, then a final @@ -15,8 +17,10 @@ name: Docker image # # Triggers: # - push to main → :main + :sha-<7> +# + :debug-main + :debug-sha-<7> # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} -# (+ :latest for stable, NOT for prereleases) +# + matching :debug-* tags +# (+ :latest/:debug-latest for stable releases) # - pull_request → build only (no push), cache stays warm # - workflow_dispatch → manual relay-tag rescue at the tag itself # @@ -95,10 +99,6 @@ jobs: runner: ubuntu-24.04-arm arch: arm64 - outputs: - # Used downstream by `merge` to stitch the manifest. - version: ${{ steps.meta.outputs.version }} - steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -163,12 +163,13 @@ jobs: org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform org.opencontainers.image.licenses=Apache-2.0 - - name: Build and push by digest - id: build + - name: Build and push release image by digest + id: build-release uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: ./Dockerfile + target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} # Push by digest, not by tag — the merge job assembles the tags @@ -180,25 +181,49 @@ jobs: cache-to: | ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - - name: Export digest + - name: Build and push debug image by digest + id: build-debug + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + target: runtime-debug + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + + - name: Export release and debug digests if: github.event_name != 'pull_request' env: - DIGEST: ${{ steps.build.outputs.digest }} + RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }} + DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }} run: | - mkdir -p /tmp/digests - touch "/tmp/digests/${DIGEST#sha256:}" + mkdir -p /tmp/digests-release /tmp/digests-debug + touch "/tmp/digests-release/${RELEASE_DIGEST#sha256:}" + touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}" - - name: Upload digest + - name: Upload release digest if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: digests-${{ matrix.arch }} - path: /tmp/digests/* + name: digests-release-${{ matrix.arch }} + path: /tmp/digests-release/* + if-no-files-found: error + retention-days: 1 + + - name: Upload debug digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digests-debug-${{ matrix.arch }} + path: /tmp/digests-debug/* if-no-files-found: error retention-days: 1 merge: - name: Merge multi-arch manifest + name: Merge ${{ matrix.variant }} multi-arch manifest if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 needs: build @@ -208,13 +233,21 @@ jobs: packages: write # push the merged manifest id-token: write # OIDC for provenance attestation on the manifest attestations: write + strategy: + fail-fast: false + matrix: + include: + - variant: release + tag_prefix: "" + - variant: debug + tag_prefix: debug- steps: - name: Download all per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests - pattern: digests-* + pattern: digests-${{ matrix.variant }}-* merge-multiple: true - name: Set up Docker Buildx @@ -237,9 +270,12 @@ jobs: # the build job's `meta` step for why match=^relay-v(.*)$, why # value=${{ inputs.version }} carries the rescue-dispatch version, # and why :latest is left to flavor.latest=auto. + flavor: | + latest=auto + prefix=${{ matrix.tag_prefix }},onlatest=true tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} - type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=${{ matrix.tag_prefix }}sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} @@ -284,11 +320,12 @@ jobs: - name: Summary env: IMAGE_NAME: ${{ env.IMAGE_NAME }} + VARIANT: ${{ matrix.variant }} MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} META_TAGS: ${{ steps.meta.outputs.tags }} run: | { - echo "### Published \`${IMAGE_NAME}\`" + echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" echo echo "**Digest:** \`${MERGED_DIGEST}\`" echo diff --git a/AGENTS.md b/AGENTS.md index cb4843a9efc..7ff0eb4d477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -343,9 +343,19 @@ Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts` (`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`. +**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri +bridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured` +in `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so +`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails +with `Cannot read properties of undefined (reading 'invoke')` — the app renders +"Community connection failed" instead of the UI under test. That looks exactly +like a product bug rather than a build mistake, so it burns real time. +`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for +you; prefer them over a manual build plus `playwright test`. + **Stale server:** `reuseExistingServer: true` means a previous build's server -serves old code. Kill port 4173 and `pnpm run build` before re-running tests -after code changes. +serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running +tests after code changes. **`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding) must run BEFORE `installMockBridge(page)` — React reads state on mount, the diff --git a/CHANGELOG.md b/CHANGELOG.md index 12239365046..956faa1ed36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## v0.5.1 + +- perf(desktop): move observer-feed archive and decrypt commands off main thread ([#3415](https://github.com/block/buzz/pull/3415)) ([`294c8c821`](https://github.com/block/buzz/commit/294c8c821de51442a8c384c0bdb66b1a10224ca0)) +- fix(desktop): preserve shared agent fidelity ([#3553](https://github.com/block/buzz/pull/3553)) ([`f7a3988ba`](https://github.com/block/buzz/commit/f7a3988ba13b590d9a55a7e8413fc3fb5ffbef18)) +- feat(agent): route Claude/GPT model families to their native gateway wire ([#3538](https://github.com/block/buzz/pull/3538)) ([`6438dedf8`](https://github.com/block/buzz/commit/6438dedf83a9dbe1853e484326911bf6c7f1618c)) +- Refine community invite limits ([#3529](https://github.com/block/buzz/pull/3529)) ([`24d90d128`](https://github.com/block/buzz/commit/24d90d1280a9325c6cbcf8eea30ac54db5afd2cb)) +- feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) ([#3463](https://github.com/block/buzz/pull/3463)) ([`c405ad1d4`](https://github.com/block/buzz/commit/c405ad1d4b1da061c11b3d26761252d41dcc62d3)) +- feat: add explicit entry for claude-opus-5 in model config ([#2831](https://github.com/block/buzz/pull/2831)) ([`90e058ebf`](https://github.com/block/buzz/commit/90e058ebf68137e048a409aec6616519379ff726)) +- fix(desktop): clear stale thread new-message pill ([#3411](https://github.com/block/buzz/pull/3411)) ([`55a3ed7b9`](https://github.com/block/buzz/commit/55a3ed7b9217cee5b23e0a5441947dc929b2a38c)) +- fix(ci): ratchet file sizes against the base tree ([#3352](https://github.com/block/buzz/pull/3352)) ([`9227bdf58`](https://github.com/block/buzz/commit/9227bdf58ad6664ae3c1078888f2181ec19c4da4)) +- feat(desktop): apply WebKit rendering workarounds at startup on Linux ([#3271](https://github.com/block/buzz/pull/3271)) ([`3ece4461d`](https://github.com/block/buzz/commit/3ece4461df8a7b9663a8e68327483b8377d4086d)) +- fix(desktop): stabilize flaky DM expansion E2E ordering assertions ([#2004](https://github.com/block/buzz/pull/2004)) ([`913d564ce`](https://github.com/block/buzz/commit/913d564ce0f35924291bf3eeab6508517a6d8d1f)) +- fix(desktop): paint community rail full height ([#3382](https://github.com/block/buzz/pull/3382)) ([`1d3b810ad`](https://github.com/block/buzz/commit/1d3b810ad70d6325718ed91e723f32c4a376d5e1)) +- feat(desktop): add custom harness inline from agent dialogs ([#3252](https://github.com/block/buzz/pull/3252)) ([`b0503d80c`](https://github.com/block/buzz/commit/b0503d80c298b1ece3b0a43b41d316829a3379e7)) +- feat(desktop): refine agent catalog sharing ([#2439](https://github.com/block/buzz/pull/2439)) ([`a35771fc4`](https://github.com/block/buzz/commit/a35771fc441cdc3c6f517f419037206783b502d2)) +- fix(desktop): keep drafts out of the Inbox All view ([#3217](https://github.com/block/buzz/pull/3217)) ([`3afa129ee`](https://github.com/block/buzz/commit/3afa129ee785cc74d921d0ba969254a8255c4cc0)) +- fix(desktop): restore the inbox icon in the sidebar ([#3341](https://github.com/block/buzz/pull/3341)) ([`00ede2e7a`](https://github.com/block/buzz/commit/00ede2e7aa7eb95571b7db3ebbd163adbf6cf74e)) +- fix(desktop): gate codex-acp on a minimum supported version ([#3254](https://github.com/block/buzz/pull/3254)) ([`4e3998f36`](https://github.com/block/buzz/commit/4e3998f36e36d68b9a93dcbd85f0864450bb8f5f)) +- feat(cli): add users set-status command for NIP-38 profile status ([#3253](https://github.com/block/buzz/pull/3253)) ([`60158fce3`](https://github.com/block/buzz/commit/60158fce3e670f11bb35d42627857ccaea50ff06)) +- fix(composer): scope multiline block formatting ([#3246](https://github.com/block/buzz/pull/3246)) ([`5457c947a`](https://github.com/block/buzz/commit/5457c947a74f5ba4b979f9c6411aa7626a858387)) + + +## v0.5.0 + +- feat(invites): add use-limited invite links ([#3141](https://github.com/block/buzz/pull/3141)) ([`d500c2d5c`](https://github.com/block/buzz/commit/d500c2d5cf5d9aabe0ca4ebebfcafdbe5f5b7fd3)) +- fix(node): bump Buzz-supplied Node runtimes past OpenClaw's >=24.15.0 floor ([#3218](https://github.com/block/buzz/pull/3218)) ([`98a7b1334`](https://github.com/block/buzz/commit/98a7b1334823ee0be3e3fa5cab7a2e349e438dab)) +- fix(desktop): preserve thread anchor through layout reflow ([#3212](https://github.com/block/buzz/pull/3212)) ([`9810d8545`](https://github.com/block/buzz/commit/9810d8545937329f229ff40d8a19edc9e3e325c1)) +- feat(search): parse from:/in:/after:/before: and pass them in the filter ([#2871](https://github.com/block/buzz/pull/2871)) ([`cb2a265b5`](https://github.com/block/buzz/commit/cb2a265b5399426e808461c1a16713754c593258)) +- fix(desktop): fetch join policies through native networking ([#2862](https://github.com/block/buzz/pull/2862)) ([`0019f8076`](https://github.com/block/buzz/commit/0019f80765e96f056e81b57789b8b5fb80936f72)) +- fix(desktop): republish agent identity records when a persona rename propagates ([#2607](https://github.com/block/buzz/pull/2607)) ([`7ca0bbd94`](https://github.com/block/buzz/commit/7ca0bbd946fd82a7008132f94d069a97bb53f94b)) +- fix(desktop): keep project Inbox previews compact ([#3193](https://github.com/block/buzz/pull/3193)) ([`de1396050`](https://github.com/block/buzz/commit/de13960505fd798070e177cb33b1663100ac06bb)) +- Inbox refactor ([#2045](https://github.com/block/buzz/pull/2045)) ([`2bd4c24b7`](https://github.com/block/buzz/commit/2bd4c24b71335e7ce272ec6de6491f7f37f4b20d)) +- Fix composer selection formatting and drop overlay ([#3172](https://github.com/block/buzz/pull/3172)) ([`99da5b7eb`](https://github.com/block/buzz/commit/99da5b7ebb19e26453e075bfb949672122b31be3)) +- Refine pending message status ([#3153](https://github.com/block/buzz/pull/3153)) ([`75588eaff`](https://github.com/block/buzz/commit/75588eaff2354d620e554c055b80ec83735ddb0a)) +- fix(desktop): recover full local storage on startup ([#3182](https://github.com/block/buzz/pull/3182)) ([`174c38e4b`](https://github.com/block/buzz/commit/174c38e4bd1ed8498641546bc4fcb6d5a4c9cede)) +- fix(desktop): keep collapsed table separators out of spoilers ([#3169](https://github.com/block/buzz/pull/3169)) ([`4d8b676bb`](https://github.com/block/buzz/commit/4d8b676bb283a1917cec5850c3b7327fe122b0c1)) +- feat(desktop): redesign agent runtime settings ([#3093](https://github.com/block/buzz/pull/3093)) ([`d98da7389`](https://github.com/block/buzz/commit/d98da7389e60cfbd79b219aa411449fe2e53a18a)) +- fix(desktop): use forward slashes for git credential.helper on Windows ([#3023](https://github.com/block/buzz/pull/3023)) ([`899531684`](https://github.com/block/buzz/commit/8995316844f7ad50552fbae67fbd35119262796f)) +- chore(desktop): add AgentCreationPreview file-size override to unblock main CI ([#3154](https://github.com/block/buzz/pull/3154)) ([`b92a1f4bf`](https://github.com/block/buzz/commit/b92a1f4bf400e7da5ab7a010cdd81a69497d8191)) +- fix(desktop): make the test loader work on Windows ([#2758](https://github.com/block/buzz/pull/2758)) ([`8bb43d519`](https://github.com/block/buzz/commit/8bb43d51912894553f2670b2d285a96cf09cd472)) +- fix(desktop): make lint and unit-test gates work on Windows ([#2943](https://github.com/block/buzz/pull/2943)) ([`545bb46b8`](https://github.com/block/buzz/commit/545bb46b824a3fbf4401062f03b72531d832ebb9)) +- feat(desktop): add search to agent emoji picker ([#2630](https://github.com/block/buzz/pull/2630)) ([`313f793c8`](https://github.com/block/buzz/commit/313f793c8753d413c22ff8edfe420d5ee78708bc)) +- fix(desktop): keep identity key help dialog readable in dark mode ([#2854](https://github.com/block/buzz/pull/2854)) ([`be275cfc6`](https://github.com/block/buzz/commit/be275cfc6c7b80fe43e9d66c6d14b6d2bbe58a10)) +- feat(acp): title agent sessions from the agent and channel name ([#3028](https://github.com/block/buzz/pull/3028)) ([`f2fe3b63c`](https://github.com/block/buzz/commit/f2fe3b63c21be55907175715c076cd3a9195b74d)) +- feat(git): use agent display name as git author name ([#3040](https://github.com/block/buzz/pull/3040)) ([`18eef633d`](https://github.com/block/buzz/commit/18eef633d88ac465c61d98f12655fbf51dc3ca44)) +- fix(deps): bump nostr to 0.44.6 for RUSTSEC-2026-0216 (NIP-44 remote DoS) ([#3135](https://github.com/block/buzz/pull/3135)) ([`31e2de196`](https://github.com/block/buzz/commit/31e2de1966672e73e026af3c54f3a1a9a2f5e103)) +- fix(desktop): read the newest pair-scoped harness log ([#3134](https://github.com/block/buzz/pull/3134)) ([`654f38490`](https://github.com/block/buzz/commit/654f384906b5c720a60a199d85031a6f1cb6efc9)) +- feat(desktop): handle project work from Inbox ([#3117](https://github.com/block/buzz/pull/3117)) ([`c5c4f390b`](https://github.com/block/buzz/commit/c5c4f390b6713256e2efb8394c59823ebad73db6)) +- fix(desktop): clarify identity key button when key exists ([#2357](https://github.com/block/buzz/pull/2357)) ([`87b3fcd3c`](https://github.com/block/buzz/commit/87b3fcd3c0131683569dd4268b099d18b25dcd5e)) +- Restore Goose and Buzz Agent to onboarding harness selection ([#2731](https://github.com/block/buzz/pull/2731)) ([`7fc0cc82d`](https://github.com/block/buzz/commit/7fc0cc82db4d9dced9c258bbe8b530164a832a77)) +- fix(desktop): render rich project work item content ([#3100](https://github.com/block/buzz/pull/3100)) ([`afb272bb7`](https://github.com/block/buzz/commit/afb272bb7b8d7d45d7de676fa97dcd5a8eefacc7)) +- feat(acp): bring your own harness (BYOH) — generic ACP runtime seam + settings gallery ([#2773](https://github.com/block/buzz/pull/2773)) ([`95fdf9788`](https://github.com/block/buzz/commit/95fdf978800982389b120c66ff5e766d785419c7)) +- feat(desktop): use collective mesh routing for Auto ([#2825](https://github.com/block/buzz/pull/2825)) ([`16d4ec335`](https://github.com/block/buzz/commit/16d4ec335e210295a9d9f77f36c1e85a18b6814a)) +- fix(desktop): strip legacy baked team instructions from stored prompts ([#3035](https://github.com/block/buzz/pull/3035)) ([`aee631448`](https://github.com/block/buzz/commit/aee63144843854ee32ed9d36a2e7511c82ddc6b0)) +- feat(agents): lower default agent parallelism from 24 to 10 ([#3038](https://github.com/block/buzz/pull/3038)) ([`5d8ede446`](https://github.com/block/buzz/commit/5d8ede446f8fdc48146fe56d389cab6bf3500f92)) +- Polish community rail and mobile pairing ([#2972](https://github.com/block/buzz/pull/2972)) ([`e6c90bb7c`](https://github.com/block/buzz/commit/e6c90bb7c430d1b2af16508b634f9a5283b7fa3b)) +- fix(desktop): remove bundled libsystemd from AppImage ([#2353](https://github.com/block/buzz/pull/2353)) ([`a31fc4d2f`](https://github.com/block/buzz/commit/a31fc4d2f35d51cdf45ff8c61fc3a07f49c665e8)) +- fix(desktop): make agent definition authoritative for model/provider/prompt ([#1968](https://github.com/block/buzz/pull/1968)) ([`8c0e8cb16`](https://github.com/block/buzz/commit/8c0e8cb1656b04ad269bce3c2deeda2a943ae78a)) +- chore(desktop): delete dead persona catalog UI cluster ([#2886](https://github.com/block/buzz/pull/2886)) ([`8e67cf399`](https://github.com/block/buzz/commit/8e67cf399d0291bcdbc69cd0402983ca030f05bb)) +- fix(desktop): surface install failures hidden by curl-pipe exit codes ([#2892](https://github.com/block/buzz/pull/2892)) ([`166c6655e`](https://github.com/block/buzz/commit/166c6655e8bca87d83ad60c087fb70a32a026baf)) +- Refactor managed-agent runtime into cohesive modules ([#2974](https://github.com/block/buzz/pull/2974)) ([`74b63e184`](https://github.com/block/buzz/commit/74b63e1846212af6e6751a62cfc631f74b1dfe07)) +- fix(desktop): make Linux AppImage GStreamer work on non-Debian distros ([#2176](https://github.com/block/buzz/pull/2176)) ([`cc6c4d347`](https://github.com/block/buzz/commit/cc6c4d3471629fad018bcf645f9471a01b9ffe2f)) +- refactor(desktop): remove Agent directory section from Agents page ([#2290](https://github.com/block/buzz/pull/2290)) ([`5d1233e84`](https://github.com/block/buzz/commit/5d1233e841b0efa91470bb45467b2c8e4284ebf6)) +- fix(desktop): enable arboard Wayland backend so Linux copies reach the Wayland clipboard ([#2904](https://github.com/block/buzz/pull/2904)) ([`ab7aa8b12`](https://github.com/block/buzz/commit/ab7aa8b1200710dbc2d7a8661ed5aab95c4199c1)) +- fix(desktop): supervise and re-arm relay-mesh runtime ([#2823](https://github.com/block/buzz/pull/2823)) ([`aa51dab9d`](https://github.com/block/buzz/commit/aa51dab9da5fef7054d03cf1a1207986d0000684)) +- fix(agents): run live Databricks discovery instead of the fallback list ([#2890](https://github.com/block/buzz/pull/2890)) ([`8eb6e3eb6`](https://github.com/block/buzz/commit/8eb6e3eb601174249642373a6a367262fa476753)) +- fix(desktop): retire prepend mode on every reader wheel ([#2913](https://github.com/block/buzz/pull/2913)) ([`07d0265cf`](https://github.com/block/buzz/commit/07d0265cfc212ef02e1c26153bf58ff46ce5ffe6)) +- fix(desktop): consolidate prepend scroll correction ([#2855](https://github.com/block/buzz/pull/2855)) ([`25e7864b3`](https://github.com/block/buzz/commit/25e7864b35f4dfd1c0ff31304a38555230a85f8d)) +- fix(desktop): track concurrent agent turns up to the harness maximum ([#2882](https://github.com/block/buzz/pull/2882)) ([`20bff5910`](https://github.com/block/buzz/commit/20bff591023daffc5ee1032cff02b54b75da3567)) +- fix(relay): preserve reconnect backoff ([#2759](https://github.com/block/buzz/pull/2759)) ([`499c5d349`](https://github.com/block/buzz/commit/499c5d349dab13bc906b1af5fe1fcb09ce2afa81)) +- refactor(relay): expose reconnect timing policy ([#2310](https://github.com/block/buzz/pull/2310)) ([`2f0041595`](https://github.com/block/buzz/commit/2f0041595d72529c06885680d2bd07ddb6a0beb4)) +- fix(desktop): clear stale working badges on agent stop/restart ([#2803](https://github.com/block/buzz/pull/2803)) ([`a64cc71f6`](https://github.com/block/buzz/commit/a64cc71f6c1605279b1a6fbd0fe904a2984cbdb0)) +- fix(desktop): surface agent rename relay profile sync failure as a warning toast ([#2279](https://github.com/block/buzz/pull/2279)) ([`5e3d2e484`](https://github.com/block/buzz/commit/5e3d2e4849c0f2512330801d804fb96f4ab72d28)) +- fix(discovery): inject PATH into Codex adapter planning ([#2767](https://github.com/block/buzz/pull/2767)) ([`6ab3835f3`](https://github.com/block/buzz/commit/6ab3835f3fe89ee215819fe8d193463c0ae7472b)) + + ## v0.4.26 - Style mobile pairing QR codes ([#2775](https://github.com/block/buzz/pull/2775)) ([`50655ac09`](https://github.com/block/buzz/commit/50655ac097fbf1a7db1a5284dccc7e2a0b0f1bfc)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87c12bf29d8..db0aea637fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,28 @@ Buzz is an agent platform, so AI-assisted PRs are welcome. No need to disclose t We squash-merge, so your PR title becomes the commit subject in `main`. Use [Conventional Commits](https://www.conventionalcommits.org/) format: `feat(mcp): add get_feed_actions tool`. The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is required. See the [Commit Messages](#commit-messages) section for the full reference. -Every commit needs a Developer Certificate of Origin sign-off, so commit with `git commit -s` — it appends the `Signed-off-by` trailer that certifies you wrote the change and can contribute it. The required **DCO Check** blocks merge without it on every commit, and it's the most common reason new PRs stall. If you already pushed unsigned commits, run `git rebase --signoff main` and force-push. Running `just hooks` installs a `commit-msg` hook that adds the trailer to commits created by `git commit` and `git merge`; other flows need their own flag — `git rebase --signoff`, `git cherry-pick -s`. +### Sign Your Commits + +```bash +git commit -s +``` + +Every commit needs a Developer Certificate of Origin (DCO) sign-off. The `-s` flag appends a `Signed-off-by` trailer that certifies you wrote the change and can contribute it under the project license. The **DCO Check** will block your PR without it. + +#### Fix unsigned commits already pushed + +```bash +git rebase --signoff main +git push --force-with-lease +``` + +#### Auto-setup for future commits + +```bash +just hooks +``` + +This installs a `commit-msg` hook that adds the sign-off trailer automatically for `git commit` and `git merge`. Other flows (`git rebase`, `git cherry-pick`) still need their own flag — `--signoff` and `-s` respectively. We review as capacity allows — focused PRs that follow this guide move fastest. @@ -77,6 +98,37 @@ Hermit pins Rust, `just`, Node, pnpm, and other tools to the versions in upfront. If you don't use Hermit, ensure your toolchain meets the minimum versions in the table above. +#### Linux: Tauri system libraries + +Hermit pins language toolchains, not system libraries. On Linux, the desktop +app's Rust crates link against GTK and WebKitGTK, so `just ci` (and any +`just desktop-tauri-*` recipe) needs these installed system-wide first. On +Debian/Ubuntu: + +```bash +sudo apt-get install -y --no-install-recommends \ + build-essential curl file libasound2-dev libayatana-appindicator3-dev \ + libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev \ + patchelf wget +``` + +This is the same list CI installs (see `.github/workflows/ci.yml`), so matching +it locally keeps your results comparable to CI. Other distributions ship these +under different package names — see the +[Tauri prerequisites](https://tauri.app/start/prerequisites/) for the +equivalents. + +Without them, `just ci` fails partway through `just check` with a pkg-config +error such as: + +``` +The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found. +``` + +If you're only touching the relay, CLI, or other server-side crates, you can +skip this and run the narrower recipes instead — `just fmt-check`, `just +clippy`, `just test-unit`, and `just test` need no GTK. + ### First-Time Setup ```bash @@ -284,9 +336,34 @@ required. The scope (in parentheses) is optional but encouraged. - How to test it manually (if applicable) - Any follow-up work deferred to a future PR -### Review Process +6. **Shows the UI** — any PR that changes the desktop or mobile UI includes + before/after screenshots (or a short recording for interactions) in the + description. We can't run every branch locally — screenshots let us review + UI changes same-day instead of waiting for someone to build your branch. + +### PRs We're Unlikely to Merge + +Some kinds of PRs usually get closed — not because they're bad ideas, but +because we can't safely review them without prior discussion: + +- **Large refactors or dependency swaps** without a prior issue agreeing on + the direction +- **Cosmetic renames or style-only churn** that doesn't fix a bug or improve + clarity +- **Entirely new features** with no prior discussion +- **Drive-by changes bundled into an unrelated fix** — split them out + +If you're considering any of these, open an issue first and we'll tell you +quickly whether it's a direction we'd merge. That saves your time as much as +ours. + +### What to Expect After You Open a PR -- We prioritize focused PRs that follow this guide and review as capacity allows. +- Maintainers triage new PRs on a best-effort cadence. Focused PRs that + follow this guide move fastest. +- Duplicates and PRs that skip this guide may be closed with a pointer here + rather than a full review. A close isn't a rejection of you or the idea — + address the gaps and reopen (or open a fresh PR) anytime. - Address review comments by pushing new commits (don't force-push during review; it makes it hard to see what changed). - Once approved, a maintainer will squash-merge your PR. diff --git a/Cargo.lock b/Cargo.lock index 9d0190868de..3b60dc4579f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,6 +925,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ + "base64", "chrono", "hex", "hmac 0.13.0", @@ -949,6 +950,7 @@ dependencies = [ "chrono", "hex", "nostr", + "rand 0.10.1", "serde", "serde_json", "sha2 0.11.0", @@ -3039,11 +3041,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if 1.0.4", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -5457,9 +5457,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.3" +version = "0.44.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" +checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" dependencies = [ "base64", "bech32", @@ -6823,15 +6823,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", "rustls", diff --git a/Cargo.toml b/Cargo.toml index 3499285f917..3ac7ee4cce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ reqwest = { version = "0.13", features = ["json", "rustls"], default-features = sha2 = "0.11" hex = "0.4" hmac = "0.13" +base64 = "0.22" # Randomness rand = "0.10" diff --git a/Dockerfile b/Dockerfile index 661be6c3a7c..d883ac6b015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,9 @@ RUN apt-get update \ ca-certificates \ git \ && rm -rf /var/lib/apt/lists/* +# Keep enough DWARF for native profilers to resolve optimized code to source +# locations. The normal runtime strips it below; runtime-debug retains it. +ENV CARGO_PROFILE_RELEASE_DEBUG=line-tables-only COPY --from=planner /build/recipe.json recipe.json # Cook the full workspace recipe — relay deps include workspace siblings, so # scoping to -p buzz-relay misses transitive deps and re-builds them later. @@ -66,8 +69,12 @@ RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ - -p buzz-pair-relay --bin buzz-pair-relay \ - && strip target/release/buzz-relay \ + -p buzz-pair-relay --bin buzz-pair-relay + +# Derive the normal release binaries from the same optimized ELF files as the +# debug image so the two variants cannot drift at code-generation time. +FROM builder AS stripped-binaries +RUN strip target/release/buzz-relay \ && strip target/release/buzz-admin \ && strip target/release/buzz-pair-relay @@ -111,8 +118,8 @@ COPY web/ web/ COPY admin-web/ admin-web/ RUN pnpm -C web build && pnpm -C admin-web build -# ─── Stage 5: runtime ─────────────────────────────────────────────────────── -FROM debian:${DEBIAN_VERSION}-slim AS runtime +# ─── Stage 5: shared runtime ──────────────────────────────────────────────── +FROM debian:${DEBIAN_VERSION}-slim AS runtime-base # OCI annotations: required for GHCR to auto-link the image to this repo and # inherit its visibility. org.opencontainers.image.source is the load-bearing @@ -135,9 +142,6 @@ RUN apt-get update \ && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \ --create-home --shell /usr/sbin/nologin buzz -COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay -COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin -COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay COPY --from=web-builder /build/web/dist /srv/buzz/web COPY --from=web-builder /build/admin-web/dist /srv/buzz/admin-web @@ -157,3 +161,18 @@ USER buzz:buzz WORKDIR /var/lib/buzz ENTRYPOINT ["/usr/local/bin/buzz-relay"] + +# Optimized binaries with line-table debug information for native profiling. +# Published under debug-* tags; runtime behavior otherwise matches the normal +# image exactly. +FROM runtime-base AS runtime-debug +COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay +COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin +COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay + +# Keep the stripped runtime as the final/default Dockerfile target so existing +# `docker build .` callers and release tags retain their current behavior. +FROM runtime-base AS runtime +COPY --from=stripped-binaries /build/target/release/buzz-relay /usr/local/bin/buzz-relay +COPY --from=stripped-binaries /build/target/release/buzz-admin /usr/local/bin/buzz-admin +COPY --from=stripped-binaries /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay diff --git a/RELEASING.md b/RELEASING.md index 9785122aadd..063b813e2ce 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,10 +57,13 @@ or mobile GitHub Release. 2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `relay-v`. 3. **The tag triggers `docker.yml`.** Stable releases update the version - aliases and `latest`; prereleases do not. + aliases and `latest`; prereleases do not. Each release also publishes an + optimized, symbol-bearing image under matching `debug-` tags (for example, + `debug-0.3.0` and `debug-latest`) for native profiling. The ordinary tags + remain stripped and are the default for deployments that do not need it. Every push to `main` continues to publish the rolling relay `:main` and -`:sha-<7>` tags. +`:sha-<7>` tags, plus matching `:debug-main` and `:debug-sha-<7>` variants. ### Mobile diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index 49202e9778d..f39ecc33c50 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -7,7 +7,12 @@ import { useState, } from "react"; import { ApiFailure, request } from "./api"; -import type { FeedbackDetail, FeedbackSummary, Report } from "./types"; +import type { + FeedbackDetail, + FeedbackSummary, + Report, + ReportDetail as ReportDetailData, +} from "./types"; import { useResource } from "./useResource"; function usePath() { @@ -131,7 +136,10 @@ function Reports() { } function ReportDetail({ id }: { id: string }) { - const resource = useResource(() => request(`/reports/${id}`), id); + const resource = useResource( + () => request(`/reports/${id}`), + id, + ); return ( {report.target} + {report.targetKind === "event" ? ( + <> +
Message
+
+ {report.message ? ( +
+ {report.message.deletedAt ? ( + Deleted + ) : null} +

{report.message.content}

+
+ Author + {report.message.authorPubkey} + Created + +
+
+ ) : ( +

+ Message content is unavailable. It may have expired or + been removed from event storage. +

+ )} +
+ + ) : null}
Note
{report.note ?? "No note provided."} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index f72747c0a76..93ebceeb141 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -496,6 +496,38 @@ dd { margin: 0; } +.reported-message { + display: grid; + justify-items: start; + gap: 1rem; + border-left: 3px solid #d7d72e; + border-radius: 0 0.8rem 0.8rem 0; + background: #f6f6f1; + padding: 1rem; +} + +.reported-message p, +.message-unavailable { + margin: 0; + overflow-wrap: anywhere; + line-height: 1.5; + white-space: pre-wrap; +} + +.reported-message-meta { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.4rem 0.75rem; + width: 100%; + color: rgb(35 30 30 / 48%); + font-size: 0.78rem; +} + +.message-unavailable { + color: rgb(35 30 30 / 60%); + font-style: italic; +} + .sensitive { border-left: 3px solid #d7d72e; border-radius: 0 0.8rem 0.8rem 0; diff --git a/admin-web/src/types.ts b/admin-web/src/types.ts index 1aab0cb9ac8..6c108377589 100644 --- a/admin-web/src/types.ts +++ b/admin-web/src/types.ts @@ -12,6 +12,17 @@ export interface Report { createdAt: string; } +export interface ReportedMessage { + authorPubkey: string; + content: string; + createdAt: string; + deletedAt: string | null; +} + +export interface ReportDetail extends Report { + message: ReportedMessage | null; +} + export interface FeedbackSummary { id: string; communityId: string; diff --git a/admin-web/tests/routes.spec.ts b/admin-web/tests/routes.spec.ts index 002ce456b39..3c965dd2d85 100644 --- a/admin-web/tests/routes.spec.ts +++ b/admin-web/tests/routes.spec.ts @@ -58,6 +58,72 @@ test("report rows render the relay response contract", async ({ page }) => { await expect(page.getByText("Unknown date")).toHaveCount(0); }); +test("event report detail renders the reported message content", async ({ + page, +}) => { + const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a08"; + await page.route(`**/api/admin/v1/reports/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc", + communityHost: "design.buzz.xyz", + reporterPubkey: "21".repeat(32), + targetKind: "event", + target: "12".repeat(32), + reportType: "spam", + status: "open", + createdAt: "2026-07-17T17:30:00Z", + message: { + authorPubkey: "31".repeat(32), + content: + "This is the complete reported message.\nIt preserves lines.", + createdAt: "2026-07-17T17:25:00Z", + deletedAt: null, + }, + }), + }), + ); + + await page.goto(`/reports/${id}`); + await expect( + page.getByText("This is the complete reported message.", { exact: false }), + ).toBeVisible(); + await expect(page.getByText("31".repeat(32))).toBeVisible(); + await expect( + page.getByText("Message content is unavailable", { exact: false }), + ).toHaveCount(0); +}); + +test("event report detail explains when message content is unavailable", async ({ + page, +}) => { + const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a09"; + await page.route(`**/api/admin/v1/reports/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc", + communityHost: "design.buzz.xyz", + reporterPubkey: "21".repeat(32), + targetKind: "event", + target: "12".repeat(32), + reportType: "spam", + status: "open", + createdAt: "2026-07-17T17:30:00Z", + message: null, + }), + }), + ); + + await page.goto(`/reports/${id}`); + await expect( + page.getByText("Message content is unavailable", { exact: false }), + ).toBeVisible(); +}); + test("feedback cards open the complete submission", async ({ page }) => { const id = "feed0000-0000-4000-8000-000000000001"; const fullBody = `${"Long feedback ".repeat(30)}end of feedback`; diff --git a/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py index f1e91d022bf..b6f5601a82c 100755 --- a/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py +++ b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py @@ -84,51 +84,75 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: ) problems = parser.add_mutually_exclusive_group() problems.add_argument( - "--dataset", "-d", default=None, + "--dataset", + "-d", + default=None, help=f"Registry dataset (default: {DEFAULT_DATASET})", ) problems.add_argument( "--path", "-p", type=Path, help="Local task or dataset directory" ) parser.add_argument( - "--include-task", "-i", action="append", default=[], + "--include-task", + "-i", + action="append", + default=[], help="Task name to include (glob, repeatable)", ) parser.add_argument( - "--exclude-task", "-x", action="append", default=[], + "--exclude-task", + "-x", + action="append", + default=[], help="Task name to exclude (glob, repeatable)", ) parser.add_argument( - "--attempts", "-k", type=int, default=DEFAULT_ATTEMPTS, + "--attempts", + "-k", + type=int, + default=DEFAULT_ATTEMPTS, help=f"Runs per problem (default: {DEFAULT_ATTEMPTS}, the leaderboard requirement)", ) parser.add_argument( - "--manifest", type=Path, default=DEFAULT_MANIFEST, + "--manifest", + type=Path, + default=DEFAULT_MANIFEST, help=f"Team manifest YAML (default: {DEFAULT_MANIFEST.name})", ) parser.add_argument( - "--endpoint-config", type=Path, default=DEFAULT_ENDPOINTS, + "--endpoint-config", + type=Path, + default=DEFAULT_ENDPOINTS, help=f"Endpoint provider/API-key mapping (default: {DEFAULT_ENDPOINTS.name})", ) - parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials") + parser.add_argument( + "--n-concurrent", "-n", type=int, default=4, help="Concurrent trials" + ) parser.add_argument( "--jobs-dir", type=Path, default=PACKAGE_ROOT / "jobs", help="Job output root" ) - parser.add_argument("--job-name", default=None, help="Job name (default: lb--)") parser.add_argument( - "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes" + "--job-name", default=None, help="Job name (default: lb--)" + ) + parser.add_argument( + "--upload", + action="store_true", + help="Upload to Harbor Hub when the job finishes", ) parser.add_argument( - "--gui", action="store_true", + "--gui", + action="store_true", help="Open the Buzz desktop app as the benchmark user to watch the run live", ) parser.add_argument( - "--fresh", action="store_true", + "--fresh", + action="store_true", help="Reset first: drop the stack's Docker volumes and the benchmark " - "GUI's app state (keys in state.json are kept)", + "GUI's app state (keys in state.json are kept)", ) parser.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="Print the underlying harbor command and exit (no stack bring-up)", ) return parser.parse_args(argv) @@ -153,7 +177,6 @@ def load_state() -> dict[str, str]: "user_secret_key": user.secret_key, "postgres_password": secrets.token_urlsafe(24), "redis_password": secrets.token_urlsafe(24), - "typesense_api_key": secrets.token_hex(16), "s3_access_key": secrets.token_hex(10), "s3_secret_key": secrets.token_hex(20), "git_hook_hmac_secret": secrets.token_hex(32), @@ -202,7 +225,6 @@ def write_env_file(state: dict[str, str]) -> Path: "POSTGRES_USER": "buzz", "POSTGRES_PASSWORD": state["postgres_password"], "REDIS_PASSWORD": state["redis_password"], - "TYPESENSE_API_KEY": state["typesense_api_key"], "BUZZ_S3_ACCESS_KEY": state["s3_access_key"], "BUZZ_S3_SECRET_KEY": state["s3_secret_key"], "BUZZ_S3_BUCKET": "buzz-media", @@ -217,14 +239,11 @@ def write_env_file(state: dict[str, str]) -> Path: def postgres_dsn(state: dict[str, str]) -> str: return ( - f"postgresql://buzz:{state['postgres_password']}" - f"@127.0.0.1:{PG_HOST_PORT}/buzz" + f"postgresql://buzz:{state['postgres_password']}@127.0.0.1:{PG_HOST_PORT}/buzz" ) -def write_provisioner_config( - state: dict[str, str], endpoint_config: Path -) -> Path: +def write_provisioner_config(state: dict[str, str], endpoint_config: Path) -> Path: """Resolve per-endpoint API keys from the environment and write the provisioner config: pinned user, keep-channels teardown.""" endpoints = json.loads(endpoint_config.read_text()) @@ -262,10 +281,14 @@ def write_provisioner_config( def compose_command(*args: str) -> list[str]: command = [ - "docker", "compose", - "--project-name", COMPOSE_PROJECT, - "--project-directory", str(STATE_DIR), - "--env-file", str(STATE_DIR / ".env"), + "docker", + "compose", + "--project-name", + COMPOSE_PROJECT, + "--project-directory", + str(STATE_DIR), + "--env-file", + str(STATE_DIR / ".env"), ] for file in COMPOSE_FILES: command += ["-f", str(file)] @@ -360,7 +383,9 @@ def linux_triple() -> str: """The musl triple matching the Docker engine that runs task containers.""" arch = subprocess.run( ["docker", "version", "--format", "{{.Server.Arch}}"], - capture_output=True, text=True, check=True, + capture_output=True, + text=True, + check=True, ).stdout.strip() try: return { @@ -385,22 +410,32 @@ def ensure_agent_binaries() -> Path: targets = AGENT_BINARIES + (FORWARDER_BINARY,) if all((bin_dir / name).is_file() for name in targets): return bin_dir - print(f"Linux agent binaries missing — cross-building for {triple} " - f"in {RUST_IMAGE} (first run only, ~2 min)...") + print( + f"Linux agent binaries missing — cross-building for {triple} " + f"in {RUST_IMAGE} (first run only, ~2 min)..." + ) LINUX_TARGET_DIR.mkdir(parents=True, exist_ok=True) (STATE_DIR / "cargo-registry").mkdir(exist_ok=True) packages = [arg for name in AGENT_BINARIES for arg in ("-p", name)] forwarder_src = FORWARDER_SOURCE.relative_to(REPO_ROOT) subprocess.run( [ - "docker", "run", "--rm", - "-v", f"{REPO_ROOT}:/src:ro", - "-v", f"{LINUX_TARGET_DIR}:/target", - "-v", f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry", - "-e", "CARGO_TARGET_DIR=/target", - "-w", "/src", + "docker", + "run", + "--rm", + "-v", + f"{REPO_ROOT}:/src:ro", + "-v", + f"{LINUX_TARGET_DIR}:/target", + "-v", + f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry", + "-e", + "CARGO_TARGET_DIR=/target", + "-w", + "/src", RUST_IMAGE, - "sh", "-c", + "sh", + "-c", "apk add --no-cache musl-dev >/dev/null && " f"cargo build --release --locked --target {triple} " + " ".join(packages) @@ -429,8 +464,13 @@ def launch_gui(state: dict[str, str]) -> subprocess.Popen: """ subprocess.run( compose_command( - "exec", "-T", "relay", - "buzz-admin", "add-member", "--pubkey", state["user_pubkey"], + "exec", + "-T", + "relay", + "buzz-admin", + "add-member", + "--pubkey", + state["user_pubkey"], ), check=True, ) @@ -445,12 +485,20 @@ def launch_gui(state: dict[str, str]) -> subprocess.Popen: ["rustc", "-vV"], capture_output=True, text=True, check=True ).stdout triple = next( - line.split(": ", 1)[1] for line in target.splitlines() if line.startswith("host: ") + line.split(": ", 1)[1] + for line in target.splitlines() + if line.startswith("host: ") ) sidecar_dir = desktop_dir / "src-tauri" / "binaries" sidecar_dir.mkdir(parents=True, exist_ok=True) binaries = ensure_binaries() - for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp", "git-credential-nostr", "buzz"): + for name in ( + "buzz-acp", + "buzz-agent", + "buzz-dev-mcp", + "git-credential-nostr", + "buzz", + ): stub = sidecar_dir / f"{name}-{triple}" if not stub.exists(): stub.touch() @@ -498,21 +546,30 @@ def leaderboard_argv( for pattern in args.exclude_task: argv += ["--exclude-task", pattern] argv += [ - "--attempts", str(args.attempts), - "--manifest", str(args.manifest), - "--endpoint-config", str(args.endpoint_config), - "--provisioner-config", str(provisioner_config), - "--agent-bin-dir", str(agent_bin_dir), + "--attempts", + str(args.attempts), + "--manifest", + str(args.manifest), + "--endpoint-config", + str(args.endpoint_config), + "--provisioner-config", + str(provisioner_config), + "--agent-bin-dir", + str(agent_bin_dir), # The relay as reachable from inside a task container: Docker's # host alias, bridged to the canonical localhost address by the # uploaded forwarder. Override the alias with # BUZZ_BENCHMARK_DOCKER_HOST if your engine exposes the host # differently. "--relay-gateway", - f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}" - f":{RELAY_HTTP_PORT}", - "--n-concurrent", str(args.n_concurrent), - "--jobs-dir", str(args.jobs_dir), + ( + f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}" + f":{RELAY_HTTP_PORT}" + ), + "--n-concurrent", + str(args.n_concurrent), + "--jobs-dir", + str(args.jobs_dir), ] if args.job_name: argv += ["--job-name", args.job_name] diff --git a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py index 6fd43ea6fbc..6eaf8d6af0f 100755 --- a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py +++ b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py @@ -45,64 +45,101 @@ # host-header tenant-bound, so agents must present its canonical Host). FORWARDER_BINARY = "relay-forwarder" -PROVIDER_ORGS = {"anthropic": "Anthropic", "openai": "OpenAI", "databricks": "Databricks"} +PROVIDER_ORGS = { + "anthropic": "Anthropic", + "openai": "OpenAI", + "databricks": "Databricks", +} def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( - description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter + description=__doc__.splitlines()[0], + formatter_class=argparse.RawDescriptionHelpFormatter, ) problems = parser.add_mutually_exclusive_group(required=True) problems.add_argument( - "--dataset", "-d", help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)" + "--dataset", + "-d", + help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)", ) problems.add_argument( "--path", "-p", type=Path, help="Local task or dataset directory" ) parser.add_argument( - "--include-task", "-i", action="append", default=[], + "--include-task", + "-i", + action="append", + default=[], help="Task name to include from the dataset (glob, repeatable)", ) parser.add_argument( - "--exclude-task", "-x", action="append", default=[], + "--exclude-task", + "-x", + action="append", + default=[], help="Task name to exclude from the dataset (glob, repeatable)", ) parser.add_argument( - "--attempts", "-k", type=int, required=True, + "--attempts", + "-k", + type=int, + required=True, help="Runs per problem (leaderboards require 5)", ) - parser.add_argument("--manifest", type=Path, required=True, help="Team manifest YAML") parser.add_argument( - "--endpoint-config", type=Path, required=True, + "--manifest", type=Path, required=True, help="Team manifest YAML" + ) + parser.add_argument( + "--endpoint-config", + type=Path, + required=True, help="JSON mapping manifest endpoint names to providers/API keys", ) parser.add_argument( - "--provisioner-config", type=Path, required=True, + "--provisioner-config", + type=Path, + required=True, help="JSON config for the Buzz relay/Postgres provisioner", ) parser.add_argument( - "--buzz-bin-dir", type=Path, default=None, + "--buzz-bin-dir", + type=Path, + default=None, help="Directory with the host buzz CLI (default: repo target/release, then target/debug)", ) parser.add_argument( - "--agent-bin-dir", type=Path, required=True, + "--agent-bin-dir", + type=Path, + required=True, help="Directory with Linux builds of buzz-acp/buzz-agent/buzz-dev-mcp " "to upload into each task container", ) parser.add_argument( - "--relay-gateway", default="", + "--relay-gateway", + default="", help="host:port of the benchmark relay as reachable from inside the " "task container (e.g. host.docker.internal:3600). When set, a " "loopback forwarder from --agent-bin-dir bridges the canonical " "relay address to this gateway", ) - parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials") - parser.add_argument("--jobs-dir", type=Path, default=Path("jobs"), help="Job output root") - parser.add_argument("--job-name", default=None, help="Job name (default: lb--)") parser.add_argument( - "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes" + "--n-concurrent", "-n", type=int, default=4, help="Concurrent trials" + ) + parser.add_argument( + "--jobs-dir", type=Path, default=Path("jobs"), help="Job output root" + ) + parser.add_argument( + "--job-name", default=None, help="Job name (default: lb--)" + ) + parser.add_argument( + "--upload", + action="store_true", + help="Upload to Harbor Hub when the job finishes", + ) + parser.add_argument( + "--dry-run", action="store_true", help="Print the harbor command and exit" ) - parser.add_argument("--dry-run", action="store_true", help="Print the harbor command and exit") return parser.parse_args(argv) @@ -110,7 +147,9 @@ def find_binaries(bin_dir: Path | None) -> dict[str, Path]: candidates = ( [bin_dir] if bin_dir is not None - else [PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")] + else [ + PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug") + ] ) for candidate in candidates: found = {name: candidate / name for name in BINARIES} @@ -146,11 +185,17 @@ def build_command( resource override would fail leaderboard static validation, so none are accepted or forwarded.""" command = [ - "harbor", "run", "--yes", - "--job-name", args.job_name, - "--jobs-dir", str(args.jobs_dir), - "-k", str(args.attempts), - "--n-concurrent", str(args.n_concurrent), + "harbor", + "run", + "--yes", + "--job-name", + args.job_name, + "--jobs-dir", + str(args.jobs_dir), + "-k", + str(args.attempts), + "--n-concurrent", + str(args.n_concurrent), ] if args.dataset: command += ["--dataset", args.dataset] @@ -250,7 +295,7 @@ def main(argv: list[str] | None = None) -> int: f"{PACKAGE_ROOT / 'testbed'} {Path(__file__).resolve()} ..." ) - result = subprocess.run(command) + result = subprocess.run(command, check=False) job_dir = args.jobs_dir / args.job_name if result.returncode != 0: print(f"harbor run failed (exit {result.returncode}); job dir: {job_dir}") diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py index b423e8aa475..1b79d233b9e 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py @@ -1,25 +1,25 @@ """Buzz orchestra custom agent for Harbor.""" from .agent import BuzzOrchestraAgent -from .manifest import ExperimentManifest, ManifestError -from .provisioning import AgentCredential, TrialHandle, TrialProvisioner -from .runtime import OrchestraRuntime, RuntimeResult from .container_runtime import ( BuzzContainerRuntime, EndpointLaunchConfig, RuntimeLaunchError, ) +from .manifest import ExperimentManifest, ManifestError +from .provisioning import AgentCredential, TrialHandle, TrialProvisioner +from .runtime import OrchestraRuntime, RuntimeResult __all__ = [ "AgentCredential", - "BuzzOrchestraAgent", "BuzzContainerRuntime", + "BuzzOrchestraAgent", "EndpointLaunchConfig", "ExperimentManifest", "ManifestError", "OrchestraRuntime", - "RuntimeResult", "RuntimeLaunchError", + "RuntimeResult", "TrialHandle", "TrialProvisioner", ] diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py index 6354e9a587b..3d1c81364f5 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py @@ -9,10 +9,10 @@ from harbor.environments.base import BaseEnvironment from harbor.models.agent.context import AgentContext +from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig from .manifest import ExperimentManifest from .provisioning import TrialProvisioner from .runtime import OrchestraRuntime -from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig class BuzzOrchestraAgent(BaseAgent): @@ -83,7 +83,7 @@ def _load_mapping( except (OSError, json.JSONDecodeError) as error: raise ValueError(f"cannot load JSON config {path}: {error}") from error if not isinstance(value, dict): - raise ValueError(f"JSON config {path} must contain an object") + raise TypeError(f"JSON config {path} must contain an object") return value @classmethod 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 3909f081f5b..149a5295a7a 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 @@ -24,7 +24,6 @@ from .provisioning import AgentCredential, TrialHandle from .runtime import RuntimeResult - DEFAULT_MAX_AGENT_ROUNDS = 32 # Container-side layout for the uploaded Buzz stack. REMOTE_ROOT = "/opt/buzz" @@ -128,12 +127,20 @@ async def run( if forwarder is not None: infra.append(forwarder) await self._buzz_json( - trial.user, trial, "users", "set-profile", "--name", + trial.user, + trial, + "users", + "set-profile", + "--name", trial.user.agent_id, ) for credential in trial.credentials: await self._buzz_json( - credential, trial, "users", "set-profile", "--name", + credential, + trial, + "users", + "set-profile", + "--name", credential.agent_id, ) agents.append( @@ -244,11 +251,17 @@ async def _start_forwarder( ) from error forwarder = _Agent( AgentCredential( - agent_id="relay-forwarder", role="infra", - nostr_secret_key="", nostr_pubkey="", nostr_auth_tag="", - llm_endpoint="", llm_api_key="", + agent_id="relay-forwarder", + role="infra", + nostr_secret_key="", + nostr_pubkey="", + nostr_auth_tag="", + llm_endpoint="", + llm_api_key="", ), - pid, log, log, + pid, + log, + log, ) deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds while True: @@ -418,9 +431,14 @@ async def _wait_for_done( await self._raise_for_dead_agents(environment, agents) polls += 1 messages = await self._buzz_json( - trial.user, trial, - "messages", "get", "--channel", trial.channel_id, - "--limit", "100", + trial.user, + trial, + "messages", + "get", + "--channel", + trial.channel_id, + "--limit", + "100", ) for message in messages: if message.get("pubkey") == orchestrator.nostr_pubkey and str( @@ -451,9 +469,7 @@ async def _raise_for_dead_agents( ) @staticmethod - async def _stop_agents( - environment: BaseEnvironment, agents: list[_Agent] - ) -> None: + async def _stop_agents(environment: BaseEnvironment, agents: list[_Agent]) -> None: """Terminate every process of the uploaded stack (acp, agent, mcp).""" if not agents: return @@ -461,14 +477,14 @@ async def _stop_agents( # to exist in task images, the /proc filesystem is. sweep = ( "for d in /proc/[0-9]*; do " - f"grep -aq {REMOTE_BIN} \"$d/cmdline\" 2>/dev/null " - "&& kill -TERM \"${d#/proc/}\" 2>/dev/null; done; true" + f'grep -aq {REMOTE_BIN} "$d/cmdline" 2>/dev/null ' + '&& kill -TERM "${d#/proc/}" 2>/dev/null; done; true' ) try: await environment.exec(sweep) await asyncio.sleep(2) await environment.exec(sweep.replace("-TERM", "-KILL")) - except Exception: # noqa: BLE001 — environment may already be gone + except Exception: # noqa: S110, BLE001 — environment may already be gone pass async def _collect_logs( @@ -476,7 +492,7 @@ async def _collect_logs( ) -> None: try: await environment.download_dir(REMOTE_LOGS, trial_dir) - except Exception: # noqa: BLE001 — best effort; env may be torn down + except Exception: # noqa: S110, BLE001 — best effort; env may be torn down pass # -- Buzz CLI as the trial user / provisioning identities ------------------- @@ -506,9 +522,14 @@ async def _send( self, credential: AgentCredential, trial: TrialHandle, content: str ) -> None: await self._buzz_json( - credential, trial, - "messages", "send", "--channel", trial.channel_id, - "--content", content, + credential, + trial, + "messages", + "send", + "--channel", + trial.channel_id, + "--content", + content, ) async def _buzz_json( @@ -614,9 +635,11 @@ def _compose_system_prompt( "", f"You are `{credential.agent_id}` (pubkey `{credential.nostr_pubkey}`).", f"The team coordinates in Buzz channel `{trial.channel_id}`.", - f"Tasks come from the user `{trial.user.agent_id}` " - f"(pubkey `{trial.user.nostr_pubkey}`); address your final report " - "to them.", + ( + f"Tasks come from the user `{trial.user.agent_id}` " + f"(pubkey `{trial.user.nostr_pubkey}`); address your final report " + "to them." + ), "", "| Name | Role | Pubkey |", "|------|------|--------|", @@ -625,8 +648,7 @@ def _compose_system_prompt( if teammate.agent_id == credential.agent_id: continue lines.append( - f"| {teammate.agent_id} | {teammate.role} " - f"| `{teammate.nostr_pubkey}` |" + f"| {teammate.agent_id} | {teammate.role} | `{teammate.nostr_pubkey}` |" ) composed = persona + "\n".join(lines) + "\n" path = trial_dir / f"{credential.agent_id}.system-prompt.md" diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py index ed2bb31cc9e..bd11f193cae 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py @@ -38,6 +38,7 @@ def run(self, *args: str) -> Any: capture_output=True, text=True, timeout=self._timeout, + check=False, env={ "BUZZ_RELAY_URL": self._relay_url, "BUZZ_PRIVATE_KEY": self._secret_key, diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py index cfda6b59fa4..d8f380387d3 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -44,7 +44,7 @@ class TestbedConfig: archive_on_teardown: bool = True -def provisioner_from_dict(config: dict[str, object]) -> "BuzzTrialProvisioner": +def provisioner_from_dict(config: dict[str, object]) -> BuzzTrialProvisioner: """Harbor CLI factory for a JSON-decoded testbed configuration.""" return BuzzTrialProvisioner(TestbedConfig(**config)) @@ -100,7 +100,7 @@ def teardown(self, handle: TrialHandle) -> None: cli = self._cli_for(handle.credentials[0]) try: cli.archive_channel(handle.channel_id) - except Exception as error: # noqa: BLE001 — idempotent re-teardown + except Exception as error: if "archived" not in str(error).lower(): raise with psycopg.connect(self._config.postgres_dsn) as conn: diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py index 2d88e339f55..e0c6d32ec44 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py @@ -37,8 +37,19 @@ def test_defaults_are_leaderboard_eligible(): def test_selectors_pass_through(): args = benchmark.parse_args( - ["--path", "/tmp/task", "-i", "cobol*", "-x", "flaky*", "-k", "1", - "--job-name", "smoke", "--dry-run"] + [ + "--path", + "/tmp/task", + "-i", + "cobol*", + "-x", + "flaky*", + "-k", + "1", + "--job-name", + "smoke", + "--dry-run", + ] ) argv = benchmark.leaderboard_argv(args, Path("p.json"), Path("b")) assert argv[argv.index("--path") + 1] == "/tmp/task" @@ -59,11 +70,15 @@ def test_state_is_generated_once_and_reused(state_dir): assert "user_pubkey" not in stored # derived, never persisted -def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, monkeypatch): +def test_provisioner_config_pins_user_and_keeps_channels( + state_dir, tmp_path, monkeypatch +): monkeypatch.setenv("FAKE_KEY_ENV", "sk-test") endpoints = tmp_path / "endpoints.json" endpoints.write_text( - json.dumps({"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}}) + json.dumps( + {"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}} + ) ) state = benchmark.load_state() path = benchmark.write_provisioner_config(state, endpoints) @@ -78,7 +93,9 @@ def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, mo assert config["relay_http_url"].startswith("http://localhost:") -def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, monkeypatch): +def test_provisioner_config_missing_api_key_is_explicit( + state_dir, tmp_path, monkeypatch +): monkeypatch.delenv("MISSING_KEY_ENV", raising=False) endpoints = tmp_path / "endpoints.json" endpoints.write_text( @@ -91,9 +108,7 @@ def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, mon def test_env_file_wires_owner_and_ports(state_dir): state = benchmark.load_state() env_path = benchmark.write_env_file(state) - env = dict( - line.split("=", 1) for line in env_path.read_text().splitlines() if line - ) + env = dict(line.split("=", 1) for line in env_path.read_text().splitlines() if line) assert env["RELAY_OWNER_PUBKEY"] == state["owner_pubkey"] assert env["BUZZ_HTTP_PORT"] == str(benchmark.RELAY_HTTP_PORT) assert env["BUZZ_PG_HOST_PORT"] == str(benchmark.PG_HOST_PORT) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py index 0f825783690..0ac794e0fa9 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py @@ -6,6 +6,7 @@ import json import coincurve + from harbor_buzz_testbed.keys import ( compute_auth_tag, encode_nsec, @@ -21,8 +22,10 @@ "auth", "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", "", - "20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da" - "34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867", + ( + "20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da" + "34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867" + ), ] diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py index 5a6b40d8cff..711b877ab48 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py @@ -14,6 +14,7 @@ import psycopg import pytest + from harbor_buzz_testbed.buzz_cli import BuzzCli, BuzzCliError from harbor_buzz_testbed.provisioner import ( BuzzTrialProvisioner, diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py index 9620be4bc80..e784de58256 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -7,6 +7,7 @@ import coincurve import pytest + from harbor_buzz_testbed.provisioner import ( BuzzTrialProvisioner, ProvisioningError, @@ -17,13 +18,13 @@ def config(**overrides) -> TestbedConfig: - defaults = dict( - relay_http_url="http://localhost:3000", - relay_ws_url="ws://host.docker.internal:3000", - owner_secret_key=OWNER_SECRET, - postgres_dsn="postgresql://unused", - llm_api_keys={"databricks/glm": "glm-key", "databricks/opus": "opus-key"}, - ) + defaults = { + "relay_http_url": "http://localhost:3000", + "relay_ws_url": "ws://host.docker.internal:3000", + "owner_secret_key": OWNER_SECRET, + "postgres_dsn": "postgresql://unused", + "llm_api_keys": {"databricks/glm": "glm-key", "databricks/opus": "opus-key"}, + } defaults.update(overrides) return TestbedConfig(**defaults) diff --git a/benchmarks/harbor-buzz-orchestra/tests/conftest.py b/benchmarks/harbor-buzz-orchestra/tests/conftest.py index bd0bcaf2ddf..b1de094d76a 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/conftest.py +++ b/benchmarks/harbor-buzz-orchestra/tests/conftest.py @@ -1,4 +1,5 @@ from typing import Any + import pytest diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py index 62c6047ab30..b305344c51c 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py @@ -1,7 +1,9 @@ from types import SimpleNamespace from uuid import uuid4 + import pytest from harbor.models.agent.context import AgentContext + from harbor_buzz_orchestra import ( AgentCredential, BuzzOrchestraAgent, @@ -73,9 +75,7 @@ async def run(self, **kwargs): async def test_agent_lifecycle_and_context(tmp_path, manifest_data): provisioner, runtime, context_id = Provisioner(), Runtime(), uuid4() - environment = SimpleNamespace( - context_id=context_id, environment_name="hello-world" - ) + environment = SimpleNamespace(context_id=context_id, environment_name="hello-world") agent = BuzzOrchestraAgent( logs_dir=tmp_path, manifest=manifest_data, diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index 8669fe980c5..ebf0eb4b5d2 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -8,8 +8,6 @@ import pytest from harbor.environments.base import ExecResult -from harbor_buzz_orchestra.manifest import ExperimentManifest -from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle from harbor_buzz_orchestra.container_runtime import ( REMOTE_BIN, REMOTE_LOGS, @@ -17,6 +15,8 @@ EndpointLaunchConfig, RuntimeLaunchError, ) +from harbor_buzz_orchestra.manifest import ExperimentManifest +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle def write_manifest(tmp_path: Path) -> ExperimentManifest: @@ -33,10 +33,20 @@ def write_manifest(tmp_path: Path) -> ExperimentManifest: { "condition": "test", "roster": [ - {"id": "orch", "kind": "orchestrator", "role": "lead", - "endpoint": "orch-model", **roster_entry}, - {"id": "worker", "kind": "worker", "role": "implementer", - "endpoint": "worker-model", **roster_entry}, + { + "id": "orch", + "kind": "orchestrator", + "role": "lead", + "endpoint": "orch-model", + **roster_entry, + }, + { + "id": "worker", + "kind": "worker", + "role": "implementer", + "endpoint": "worker-model", + **roster_entry, + }, ], "prices": { name: { @@ -162,10 +172,7 @@ def test_user_relay_url_prefers_host_view(tmp_path): == "http://localhost:3600" ) # pre-v1.2 handles fall back to deriving http from the agents' ws view. - assert ( - rt._user_relay_url(trial_handle(())) - == "http://host.docker.internal:3600" - ) + assert rt._user_relay_url(trial_handle(())) == "http://host.docker.internal:3600" with pytest.raises(RuntimeLaunchError, match="ws://"): rt._cli_relay_url("http://relay") @@ -209,16 +216,21 @@ async def test_forwarder_bridges_the_canonical_relay_address(tmp_path): forwarder_binary=str(forwarder), ) trial = TrialHandle( - run_id="run", trial_id="trial", manifest_hash="hash", - relay_ws_url="ws://localhost:3600", channel_id="channel", - credentials=(), user=user_credential(), + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://localhost:3600", + channel_id="channel", + credentials=(), + user=user_credential(), ) environment = Environment( responses={ FORWARDER: ExecResult(stdout="99\n", stderr="", return_code=0), "cat ": ExecResult( stdout="forwarding 127.0.0.1:3600 -> host.docker.internal:3600", - stderr="", return_code=0, + stderr="", + return_code=0, ), } ) @@ -295,9 +307,7 @@ class ReadyEnvironment(Environment): async def exec(self, command, env=None, **kwargs): if command.startswith("cat "): agent_id = re.search(r"([\w-]+)\.stdout\.log", command).group(1) - return ExecResult( - stdout=logs[agent_id], stderr="", return_code=0 - ) + return ExecResult(stdout=logs[agent_id], stderr="", return_code=0) return ExecResult(stdout="", stderr="", return_code=0) from harbor_buzz_orchestra.container_runtime import _Agent @@ -326,9 +336,7 @@ async def exec(self, command, env=None, **kwargs): async def test_dead_agent_processes_fail_the_trial(tmp_path): from harbor_buzz_orchestra.container_runtime import _Agent - agents = [ - _Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e") - ] + agents = [_Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e")] environment = Environment( responses={ "kill -0": ExecResult(stdout="DEAD:worker-1\n", stderr="", return_code=0) diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py index 36533db3bf9..f8230036b31 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py @@ -1,6 +1,8 @@ import copy + import pytest import yaml + from harbor_buzz_orchestra import ExperimentManifest, ManifestError diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py index ed048ee5d93..451de72e79b 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py @@ -109,8 +109,16 @@ def test_forbidden_flags_are_not_accepted(tmp_path): for flag in FORBIDDEN_FLAGS: with pytest.raises(SystemExit): run_leaderboard.parse_args( - ["--dataset", "d", "--attempts", "5", - "--agent-bin-dir", str(tmp_path), flag, "1"] + [ + "--dataset", + "d", + "--attempts", + "5", + "--agent-bin-dir", + str(tmp_path), + flag, + "1", + ] ) diff --git a/bin/.node-24.14.0.pkg b/bin/.node-24.15.0.pkg similarity index 100% rename from bin/.node-24.14.0.pkg rename to bin/.node-24.15.0.pkg diff --git a/bin/corepack b/bin/corepack index 07e8834efbf..4df063726ce 120000 --- a/bin/corepack +++ b/bin/corepack @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/bin/node b/bin/node index 07e8834efbf..4df063726ce 120000 --- a/bin/node +++ b/bin/node @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/bin/npm b/bin/npm index 07e8834efbf..4df063726ce 120000 --- a/bin/npm +++ b/bin/npm @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/bin/npx b/bin/npx index 07e8834efbf..4df063726ce 120000 --- a/bin/npx +++ b/bin/npx @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 78db7ff718b..8a698954a03 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -187,6 +187,17 @@ pub struct AcpClient { /// Other agents may leave this unset — readers must treat `None` as /// "no active run to steer into" and fall back to cancel+merge. active_run_id: Option, + /// Whether the agent advertised `_meta.steering.supported: true` in its + /// `initialize` response, meaning it implements the cross-adapter + /// [`ACP_STEER_METHOD`] extension. + /// + /// Set once by [`initialize`](Self::initialize); `false` for agents that + /// omit the key. This is the **only** gate on writing an + /// [`ACP_STEER_METHOD`] request. It must never be replaced by error-code + /// probing: codex-acp answers unrecognized extension methods with `{}` — + /// a JSON-RPC *success*, not `-32601` — which the main loop would read as + /// a delivered steer and drop the user's message from the queue. + steering_supported: bool, /// Per-turn channel for receiving goose-native non-cancelling steer /// requests from the main loop. Installed by /// [`install_steer_rx`](Self::install_steer_rx) at dispatch and @@ -344,6 +355,38 @@ pub(crate) fn build_codex_config_env( Ok(Some(serde_json::Value::Object(base).to_string())) } +/// goose's non-standard mid-turn steer method. Requires `expectedRunId`, so it +/// is only usable once a `session_info_update` has supplied +/// `_meta.goose.activeRunId`. Emitted by goose and buzz-agent only. +const GOOSE_STEER_METHOD: &str = "_goose/unstable/session/steer"; + +/// The cross-adapter mid-turn steer method, shipped by claude-agent-acp +/// (`src/acp-agent.ts:200`) and codex-acp (`src/AcpExtensions.ts:11`). +/// Params are `{sessionId, prompt}` — no run id — and the result is +/// `{outcome}`. Gated on [`AcpClient::steering_supported`]. +const ACP_STEER_METHOD: &str = "_session/steering"; + +/// `outcome` value meaning the steer was applied to the turn Buzz is waiting +/// on, which therefore keeps running. +const STEER_OUTCOME_INJECTED: &str = "injected"; + +/// `outcome` value meaning the turn Buzz was steering had already finished, so +/// the adapter began a fresh turn carrying the message. Still a delivery +/// success, but the awaited turn is over — see the steer-response arm for why +/// this must not renew the hard deadline. +const STEER_OUTCOME_STARTED_NEW_TURN: &str = "startedNewTurn"; + +/// Which wire method carried an in-flight steer request, recorded so the +/// response arm decodes the shape that method actually returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SteerTransport { + /// [`GOOSE_STEER_METHOD`] — any success result is a delivered steer. + Goose, + /// [`ACP_STEER_METHOD`] — success carries an `outcome` that must be + /// positively recognized before the steer counts as delivered. + AcpExtension, +} + fn build_client_capabilities() -> serde_json::Value { serde_json::json!({ // Signal to ACP adapters that Buzz can hand users to terminal-native @@ -447,12 +490,22 @@ impl AcpClient { // entry falls through to the standard operator-wins treatment below. let codex_merge_active = codex_config_value.is_some(); + // Per-runtime environment defaults (e.g. Hermes MCP-startup isolation). + // Applied first so both persona `extra_env` (below, via `Command::env` + // key replacement) and inherited parent env (via the parent-presence + // check) override them. + for &(key, value) in crate::config::default_agent_env(command) { + if std::env::var_os(key).is_none() { + cmd.env(key, value); + } + } + for (key, value) in extra_env { if key == "CODEX_CONFIG" && codex_merge_active { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } - if std::env::var(key).is_err() { + if std::env::var_os(key).is_none() { cmd.env(key, value); } } @@ -494,6 +547,7 @@ impl AcpClient { observer_agent_index: None, observer_context: ObserverContext::default(), active_run_id: None, + steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), }) @@ -536,11 +590,20 @@ impl AcpClient { /// /// Must be called exactly once, before any other ACP method. /// The caller may inspect `agentCapabilities` in the returned value. + /// + /// Records `_meta.steering.supported` into + /// [`steering_supported`](Self::steering_supported) so the read loop's steer + /// arm can choose [`ACP_STEER_METHOD`] for adapters that implement it. + /// Parsed here rather than at each call site so no caller can forget it. pub async fn initialize(&mut self) -> Result { // Requesting version 2 is an intentional temporary pin — we are squatting // on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges. let params = build_initialize_params(); let result = self.send_request("initialize", params).await?; + self.steering_supported = result + .pointer("/_meta/steering/supported") + .and_then(|v| v.as_bool()) + .unwrap_or(false); tracing::debug!(target: "acp::init", "initialize response: {result}"); Ok(result) } @@ -558,6 +621,9 @@ impl AcpClient { /// `cwd` must be an absolute path. `mcp_servers` may be empty. /// `system_prompt` is included in the request when `Some` — agents that /// support the field will use it; others ignore unknown fields per JSON-RPC. + /// `session_title` rides in `_meta.sessionTitle` when `Some`; `_meta` is + /// omitted entirely otherwise, since adapters may distinguish an absent + /// member from a null one. /// Callers use [`extract_model_config_options`] and [`extract_model_state`] /// to pull model info from the raw result. pub async fn session_new_full( @@ -565,6 +631,7 @@ impl AcpClient { cwd: &str, mcp_servers: Vec, system_prompt: Option<&str>, + session_title: Option<&str>, ) -> Result { let mut params = serde_json::json!({ "cwd": cwd, @@ -573,6 +640,9 @@ impl AcpClient { if let Some(sp) = system_prompt { params["systemPrompt"] = serde_json::Value::String(sp.to_owned()); } + if let Some(title) = session_title { + params["_meta"] = serde_json::json!({ "sessionTitle": title }); + } let result = self.send_request("session/new", params).await?; let session_id = result["sessionId"] .as_str() @@ -594,9 +664,10 @@ impl AcpClient { cwd: &str, mcp_servers: Vec, system_prompt: Option<&str>, + session_title: Option<&str>, ) -> Result { Ok(self - .session_new_full(cwd, mcp_servers, system_prompt) + .session_new_full(cwd, mcp_servers, system_prompt, session_title) .await? .session_id) } @@ -770,6 +841,15 @@ impl AcpClient { self.active_run_id.as_deref() } + /// Whether the agent advertised the [`ACP_STEER_METHOD`] extension at + /// `initialize` time (`_meta.steering.supported`). + /// + /// The read loop's steer arm reads the field directly; this accessor exists + /// for the supervisor's post-initialize log line. + pub fn steering_supported(&self) -> bool { + self.steering_supported + } + /// Consume and return the per-turn usage record computed from the most /// recent `_goose/unstable/session/update` notification. /// @@ -1211,14 +1291,18 @@ impl AcpClient { // so the ack_tx oneshot is never leaked silently). let mut steer_rx = self.steer_rx.take(); - // Tracks the in-flight steer write: `(request_id, ack_tx)`. While - // `Some`, the steer arm is gated off so we don't stack writes, + // Tracks the in-flight steer write: `(request_id, transport, ack_tx)`. + // While `Some`, the steer arm is gated off so we don't stack writes, // and a response matching `id` is routed to the ack_tx instead - // of being treated as the prompt result. Drained on every return - // path with `PromptCompletedNeutral` so callers are never left - // hanging. - let mut pending_steer: Option<(u64, tokio::sync::oneshot::Sender)> = - None; + // of being treated as the prompt result. `transport` records which + // method was written so the response arm decodes the result shape + // that method actually returns. Drained on every return path with + // `PromptCompletedNeutral` so callers are never left hanging. + let mut pending_steer: Option<( + u64, + SteerTransport, + tokio::sync::oneshot::Sender, + )> = None; let now = Instant::now(); let mut idle_deadline = now + idle_timeout; @@ -1243,7 +1327,7 @@ impl AcpClient { // exists). Check the classified deadline here so a steady- // stream agent is still bounded. if Instant::now() >= next_deadline { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { // Prompt is timing out — release the withheld event via // PromptCompletedNeutral (no fallback signal: there is // no in-flight turn to signal once we return, and @@ -1278,39 +1362,64 @@ impl AcpClient { None => None, } }, if pending_steer.is_none() => { - // Selected: build steer params at write time using the - // lexical `session_id` and the freshest `active_run_id`. + // Selected: choose the steer transport and build its + // params at write time using the lexical `session_id` + // and the freshest `active_run_id`. // // `active_run_id` is updated by `session/update` // notifications inside this very loop; reading it here // (rather than snapshotting at dispatch) guarantees the // value matches what goose's run-id check will compare - // against. If it's `None`, no `session/update` has - // arrived yet so we cannot form a valid `expectedRunId` - // — ack `ExpectedRunIdMissing` and drop the request - // without writing anything. The main loop maps this to - // the universal cancel+merge `Steer` fallback. - match self.active_run_id.clone() { + // against. + // + // Transport precedence: + // Some(run_id) → GOOSE_STEER_METHOD. goose + // wins whenever a run id exists: `expectedRunId` is + // strictly more precise about *which* run is steered. + // None + steering_supported → ACP_STEER_METHOD, the + // cross-adapter extension (claude-agent-acp, + // codex-acp), which takes no run id. + // None + !steering_supported → write nothing and ack + // `ExpectedRunIdMissing`; the main loop maps this to + // the universal cancel+merge `Steer` fallback. + // + // The capability flag is the ONLY gate on writing + // ACP_STEER_METHOD. Probing an unknown method is unsafe: + // codex-acp answers unrecognized extension methods with + // `{}` — a JSON-RPC success — which would be read as a + // delivered steer and silently drop the user's message. + let prompt_block_refs: Vec<&str> = + req.prompt_blocks.iter().map(String::as_str).collect(); + let selected = match (&self.active_run_id, self.steering_supported) { + (Some(run_id), _) => Some(( + SteerTransport::Goose, + GOOSE_STEER_METHOD, + build_goose_steer_params(session_id, run_id, &prompt_block_refs), + )), + (None, true) => Some(( + SteerTransport::AcpExtension, + ACP_STEER_METHOD, + build_acp_steer_params(session_id, &prompt_block_refs), + )), + (None, false) => None, + }; + match selected { None => { tracing::warn!( - "goose-native steer: no active_run_id at write time \ - (no session/update seen yet) — falling back to cancel+merge" + "steer: no active_run_id and agent did not advertise \ + {ACP_STEER_METHOD} — falling back to cancel+merge" ); let _ = req.ack_tx.send(crate::pool::SteerAck::Err( crate::pool::SteerError::ExpectedRunIdMissing, )); } - Some(run_id) => { + Some((transport, method, params)) => { let id = self.next_id; self.next_id += 1; - let prompt_block_refs: Vec<&str> = - req.prompt_blocks.iter().map(String::as_str).collect(); - let params = - build_steer_params(session_id, &run_id, &prompt_block_refs); let msg = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "method": "_goose/unstable/session/steer", + "method": method, "params": params, }); tracing::debug!( @@ -1320,11 +1429,11 @@ impl AcpClient { ); match self.write_ndjson(&msg).await { Ok(()) => { - pending_steer = Some((id, req.ack_tx)); + pending_steer = Some((id, transport, req.ack_tx)); } Err(e) => { tracing::warn!( - "goose-native steer write failed: {e} — releasing withheld event" + "steer write failed ({method}): {e} — releasing withheld event" ); let _ = req.ack_tx.send(crate::pool::SteerAck::Err( crate::pool::SteerError::Transport(e.to_string()), @@ -1343,7 +1452,7 @@ impl AcpClient { // would catch this anyway, but firing the deadline arm // here makes the wakeup immediate (no extra reader poll // round-trip when stdout is idle). - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } if idle_fires_first { @@ -1367,13 +1476,13 @@ impl AcpClient { match read_result { None => { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::AgentExited); } Some(Err(LinesCodecError::MaxLineLengthExceeded)) => { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::Protocol( @@ -1381,7 +1490,7 @@ impl AcpClient { )); } Some(Err(e)) => { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::Io(std::io::Error::other(e))); @@ -1424,13 +1533,14 @@ impl AcpClient { // share the `no method` guard. if let Some(id) = msg.get("id") { if msg.get("method").is_none() { - if let Some((steer_id, _)) = pending_steer.as_ref() { + if let Some((steer_id, _, _)) = pending_steer.as_ref() { if *id == serde_json::json!(*steer_id) { // Take the ack_tx out and route the // response. We do not return — keep // reading until the prompt response // arrives. - let (_, ack_tx) = pending_steer.take().expect("just checked"); + let (_, transport, ack_tx) = + pending_steer.take().expect("just checked"); let ack = if let Some(error) = msg.get("error") { let code = error .get("code") @@ -1441,16 +1551,83 @@ impl AcpClient { crate::pool::SteerError::AgentError { code, message }, ) } else { - let renew_now = Instant::now(); - let new_deadline = renew_now + max_duration; - if new_deadline > hard_deadline { - hard_deadline = new_deadline; - self.current_hard_deadline = Some(new_deadline); - tracing::info!( - "steer success: renewed hard deadline ({max_duration:?} from now)" - ); + // Success result. Whether it counts as + // a delivered steer — and whether the + // turn Buzz awaits is still running — + // depends on the transport. + let outcome = match transport { + // goose returns no outcome field; + // a success response means the + // steer landed in the live run. + SteerTransport::Goose => Some(STEER_OUTCOME_INJECTED), + // The outcome must be positively + // recognized. An unknown or absent + // value (codex-acp answers + // unrecognized ext methods with a + // bare `{}`) is a rejection, never + // a delivery — treating it as + // success would drop the event. + SteerTransport::AcpExtension => msg + .pointer("/result/outcome") + .and_then(|v| v.as_str()) + .filter(|o| { + *o == STEER_OUTCOME_INJECTED + || *o == STEER_OUTCOME_STARTED_NEW_TURN + }), + }; + match outcome { + Some(STEER_OUTCOME_STARTED_NEW_TURN) => { + // Delivered, but into a NEW + // turn: the one this read loop + // is awaiting had already + // finished. Renewing the hard + // deadline here would extend + // the clock on a settled turn, + // so leave it alone and let the + // prompt response land on its + // original budget. + tracing::info!( + "steer accepted as {STEER_OUTCOME_STARTED_NEW_TURN}: \ + awaited turn had ended — hard deadline not renewed" + ); + crate::pool::SteerAck::Success + } + Some(_) => { + let renew_now = Instant::now(); + let new_deadline = renew_now + max_duration; + if new_deadline > hard_deadline { + hard_deadline = new_deadline; + self.current_hard_deadline = Some(new_deadline); + tracing::info!( + "steer success: renewed hard deadline ({max_duration:?} from now)" + ); + } + crate::pool::SteerAck::Success + } + None => { + // Report the raw string when + // there is one, so logs read + // `failed` not `"failed"`; + // fall back to the JSON for a + // non-string value. + let reported = match msg.pointer("/result/outcome") + { + None => "".to_string(), + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + }; + tracing::warn!( + "steer rejected: {ACP_STEER_METHOD} returned \ + unrecognized outcome {reported} — releasing \ + withheld event for cancel+merge" + ); + crate::pool::SteerAck::Err( + crate::pool::SteerError::OutcomeRejected { + outcome: reported, + }, + ) + } } - crate::pool::SteerAck::Success }; let _ = ack_tx.send(ack); continue; @@ -1458,13 +1635,13 @@ impl AcpClient { } if *id == serde_json::json!(expected_id) { if let Some(error) = msg.get("error") { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx .send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(agent_error_from_json(error)); } - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } @@ -1523,7 +1700,10 @@ impl AcpClient { /// Takes `&mut self` (not `&self`) because some updates carry agent state /// the client must observe — notably goose's `session_info_update` with /// `_meta.goose.activeRunId`, which seeds [`active_run_id`](Self::active_run_id) - /// so callers can target `_goose/unstable/session/steer` at the correct run. + /// so the steer arm can target `_goose/unstable/session/steer` at the + /// correct run. Agents that never emit it (claude-agent-acp, codex-acp) + /// leave it `None` and are steered via `_session/steering` instead, which + /// needs no run id. fn handle_session_update(&mut self, msg: &serde_json::Value) -> bool { let update = &msg["params"]["update"]; let update_type = update @@ -1654,6 +1834,11 @@ impl AcpClient { session_id = %notif.session_id, input = payload.accumulated_input_tokens, output = payload.accumulated_output_tokens, + // A subset of `input`, logged so downstream accounting can + // price it at the provider's cached rate. Always emitted, + // including as 0, so a parser can tell "no cache hits" + // apart from "this build predates the field". + cached = payload.accumulated_cached_input_tokens, "goose usage update" ); self.goose_usage.record(¬if.session_id, payload); @@ -1788,22 +1973,44 @@ fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json:: /// matches goose's *current* run (it advances on each `session/update`). /// See [`crate::pool::SteerRequest`] for why this is the read loop's job /// and not the main loop's. -fn build_steer_params( +fn build_goose_steer_params( session_id: &str, expected_run_id: &str, prompt_blocks: &[&str], ) -> serde_json::Value { - let blocks: Vec = prompt_blocks - .iter() - .map(|text| serde_json::json!({ "type": "text", "text": text })) - .collect(); serde_json::json!({ "sessionId": session_id, "expectedRunId": expected_run_id, - "prompt": blocks, + "prompt": steer_prompt_blocks(prompt_blocks), }) } +/// Build the params for an [`ACP_STEER_METHOD`] request. +/// +/// Wire shape: +/// ```json +/// { "sessionId": "...", "prompt": [{"type":"text","text":"..."}, ...] } +/// ``` +/// +/// Deliberately carries **no** `expectedRunId`: the cross-adapter method +/// steers whatever turn is currently running and neither claude-agent-acp nor +/// codex-acp emits a run id to target. +fn build_acp_steer_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value { + serde_json::json!({ + "sessionId": session_id, + "prompt": steer_prompt_blocks(prompt_blocks), + }) +} + +/// Render steer body strings as ACP `text` content blocks. Shared by both +/// steer transports so the prompt shape cannot drift between them. +fn steer_prompt_blocks(prompt_blocks: &[&str]) -> Vec { + prompt_blocks + .iter() + .map(|text| serde_json::json!({ "type": "text", "text": text })) + .collect() +} + /// Build a JSON-RPC permission response with `outcome: "selected"`. fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value { serde_json::json!({ @@ -1846,7 +2053,8 @@ pub enum ModelSwitchMethod { /// Extract `configOptions` entries with `category == "model"` from a `session/new` result. /// -/// Returns the raw JSON array entries. Each entry has `configId`, `displayName`, +/// Returns the raw JSON array entries. Each entry has `configId` (spelled `id` +/// by some adapters, e.g. claude-agent-acp), `displayName`, /// `options: [{ value, displayName }]`, etc. pub fn extract_model_config_options(result: &serde_json::Value) -> Vec { result["configOptions"] @@ -1880,7 +2088,14 @@ pub fn resolve_model_switch_method( // 1. Search stable configOptions for a "model"-category entry whose // options contain a value matching desired_model. for config_opt in extract_model_config_options(session_new_result) { - let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) { + // Adapters disagree on the key: the ACP spec says `configId`, but + // claude-agent-acp emits `id`. Accept both; the set request always + // uses `configId` on the wire. + let config_id = match config_opt + .get("configId") + .or_else(|| config_opt.get("id")) + .and_then(|v| v.as_str()) + { Some(id) => id, None => continue, }; @@ -2456,6 +2671,36 @@ mod tests { ); } + #[test] + fn resolve_accepts_id_keyed_config_options() { + // claude-agent-acp (observed on v0.61.0) keys config options with + // `id` instead of the spec's `configId`. Payload mirrors its real + // `session/new` response. + let result = serde_json::json!({ + "configOptions": [{ + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "default", + "options": [ + { "value": "default", "name": "Default" }, + { "value": "opus[1m]", "name": "Opus" }, + { "value": "sonnet", "name": "Sonnet" } + ] + }], + "models": null + }); + let method = super::resolve_model_switch_method(&result, "opus[1m]"); + assert_eq!( + method, + Some(super::ModelSwitchMethod::ConfigOption { + config_id: "model".to_string(), + option_value: "opus[1m]".to_string(), + }) + ); + } + #[test] fn resolve_falls_back_to_unstable() { let result = serde_json::json!({ @@ -2607,6 +2852,78 @@ mod tests { .expect("failed to spawn test script") } + /// Spawn a probe script whose file name carries a runtime identity (e.g. + /// `hermes-acp`) and return the value of `var` as the child observed it. + /// `` means the child did not receive the var. + #[cfg(unix)] + async fn spawn_named_and_read_child_env( + file_name: &str, + var: &str, + extra_env: &[(String, String)], + ) -> String { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("buzz-acp-env-probe-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create env probe dir"); + let path = dir.join(file_name); + std::fs::write( + &path, + format!("#!/bin/sh\nprintf '%s\\n' \"${{{var}:-}}\"\n"), + ) + .expect("write env probe script"); + let mut permissions = std::fs::metadata(&path).expect("stat probe").permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&path, permissions).expect("chmod probe"); + + let mut client = AcpClient::spawn( + path.to_str().expect("probe path is UTF-8"), + &[], + extra_env, + false, + ) + .await + .expect("spawn env probe script"); + let observed = client + .reader + .next() + .await + .unwrap_or_else(|| panic!("child produced no output for {var}")) + .expect("child stdout was not readable"); + client.shutdown().await; + std::fs::remove_dir_all(&dir).expect("remove env probe dir"); + observed + } + + /// Buzz-owned Hermes processes get the configured-MCP isolation default, + /// and an explicit persona entry still overrides it (defaults are applied + /// before `extra_env`, so the later `Command::env` write wins). + #[cfg(unix)] + #[tokio::test] + async fn spawn_applies_runtime_env_defaults_with_extra_env_precedence() { + const VAR: &str = "HERMES_ACP_SKIP_CONFIGURED_MCP"; + if std::env::var_os(VAR).is_some() { + // Inherited parent values win over both layers; the default and + // override behavior below is unobservable in such an environment. + return; + } + + assert_eq!( + spawn_named_and_read_child_env("hermes-acp", VAR, &[]).await, + "1", + "Hermes spawns must default {VAR}=1" + ); + assert_eq!( + spawn_named_and_read_child_env("hermes-acp", VAR, &[(VAR.into(), "0".into())]).await, + "0", + "an explicit extra_env entry must override the runtime default" + ); + assert_eq!( + spawn_named_and_read_child_env("other-agent", VAR, &[]).await, + "", + "non-Hermes spawns must not receive Hermes defaults" + ); + } + #[tokio::test] async fn idle_timeout_fires_on_silent_process() { let mut client = spawn_script("sleep 10").await; @@ -2954,7 +3271,7 @@ mod tests { .expect("initialize should succeed"); let resp = client - .session_new_full("/tmp", vec![], Some("Custom system prompt")) + .session_new_full("/tmp", vec![], Some("Custom system prompt"), None) .await .expect("session_new_full should succeed"); @@ -3039,7 +3356,7 @@ mod tests { .expect("initialize should succeed"); let resp = client - .session_new_full("/tmp", vec![], None) + .session_new_full("/tmp", vec![], None, None) .await .expect("session_new_full should succeed"); @@ -3051,6 +3368,61 @@ mod tests { ); } + #[tokio::test] + async fn session_new_full_sends_session_title_in_meta_when_some() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full("/tmp", vec![], None, Some("Fizz · #buzz-dev")) + .await + .expect("session_new_full should succeed"); + + let received = &resp.raw["_receivedRequest"]; + assert_eq!( + received["params"]["_meta"]["sessionTitle"].as_str(), + Some("Fizz · #buzz-dev"), + "title should ride in _meta.sessionTitle, out of band from the prompt" + ); + } + + #[tokio::test] + async fn session_new_full_omits_meta_when_session_title_none() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full("/tmp", vec![], None, None) + .await + .expect("session_new_full should succeed"); + + let received = &resp.raw["_receivedRequest"]; + assert!( + received["params"].get("_meta").is_none(), + "_meta should be absent entirely, not an empty object or null" + ); + } + // ── Goose-native steer scaffold (PR follow-up to #1160) ────────────── /// Helper: spawn an inert `cat` subprocess so we have a real AcpClient @@ -3364,6 +3736,412 @@ mod tests { } } + // ── Cross-harness steer transport tests ─────────────────────────────── + // + // These cover the `_session/steering` transport added alongside the + // goose-native method: capability capture at `initialize`, write-time + // transport selection, and outcome decoding. Wire-shape assertions read + // the actual serialized request bytes via `capture_steer_request` rather + // than inferring the shape from response-id routing. + + /// Spawn a client whose script captures the first line written to its + /// stdin into `capture_path`, then emits `response` (already-serialized + /// JSON-RPC) and idles. + /// + /// The steer request is the first thing this read loop writes, so the + /// captured line IS the steer request bytes. + async fn spawn_steer_capture_script( + capture_path: &std::path::Path, + response: &str, + ) -> AcpClient { + let script = format!( + "read -r line; printf '%s' \"$line\" > {capture}; \ + printf '%s\\n' '{response}'; sleep 10", + capture = capture_path.display(), + response = response, + ); + spawn_script(&script).await + } + + /// Drive one steer through the read loop and return + /// `(captured_request_bytes, ack)`. + /// + /// `capture_path` may be absent afterwards when the arm wrote nothing — + /// callers assert on that. The read loop is expected to exit via a + /// timeout or EOF; the ack is what these tests care about. + async fn run_one_steer( + client: &mut AcpClient, + capture_path: &std::path::Path, + ) -> (Option, crate::pool::SteerAck) { + let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1); + client.install_steer_rx(steer_rx); + + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); + let send_task = tokio::spawn(async move { + steer_tx + .send(crate::pool::SteerRequest { + prompt_blocks: vec!["steer body".into()], + ack_tx, + }) + .await + .expect("steer_tx send should succeed"); + }); + + let idle = std::time::Duration::from_millis(800); + let max_dur = std::time::Duration::from_secs(10); + let hard_deadline = tokio::time::Instant::now() + max_dur; + let _ = client + .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur) + .await; + send_task.await.expect("send_task should complete"); + + let ack = ack_rx + .await + .expect("ack oneshot must have received a SteerAck"); + (std::fs::read_to_string(capture_path).ok(), ack) + } + + /// Unique temp path for one test's captured request bytes. + fn capture_path(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("buzz-acp-steer-capture"); + std::fs::create_dir_all(&dir).expect("create capture dir"); + let path = dir.join(format!("{name}.json")); + let _ = std::fs::remove_file(&path); + path + } + + /// Mark a client as having advertised `_meta.steering.supported` without + /// running a real `initialize` handshake. The capability-parsing tests + /// cover the handshake itself. + fn set_steering_supported(client: &mut AcpClient) { + client.steering_supported = true; + } + + /// Run `initialize` against a script that replies with `init_result` as + /// the JSON-RPC result, and return the resulting `steering_supported`. + async fn steering_supported_after_initialize(init_result: &str) -> bool { + let script = format!( + "read -r _init; printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{result}}}'; \ + sleep 5", + result = init_result, + ); + let mut client = spawn_script(&script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + client.steering_supported() + } + + /// Test 1a: an adapter advertising `_meta.steering.supported: true` + /// (claude-agent-acp `src/acp-agent.ts:1444`, codex-acp + /// `src/CodexAcpServer.ts:247`) is recorded as steering-capable. + #[tokio::test] + async fn initialize_records_steering_supported_when_advertised() { + let supported = steering_supported_after_initialize( + r#"{"protocolVersion":2,"agentCapabilities":{},"_meta":{"steering":{"supported":true}}}"#, + ) + .await; + assert!( + supported, + "_meta.steering.supported: true must set steering_supported" + ); + } + + /// Test 1b: no `_meta` at all (goose, buzz-agent, any older adapter) must + /// leave the capability off — this is what keeps a steer off the wire for + /// agents that never implemented it. + #[tokio::test] + async fn initialize_leaves_steering_unsupported_when_meta_absent() { + let supported = + steering_supported_after_initialize(r#"{"protocolVersion":2,"agentCapabilities":{}}"#) + .await; + assert!( + !supported, + "absent _meta must leave steering_supported false" + ); + } + + /// Test 1c: an explicit `supported: false` is respected, not treated as + /// "the key exists so it must work". + #[tokio::test] + async fn initialize_leaves_steering_unsupported_when_explicitly_false() { + let supported = steering_supported_after_initialize( + r#"{"protocolVersion":2,"_meta":{"steering":{"supported":false}}}"#, + ) + .await; + assert!( + !supported, + "_meta.steering.supported: false must leave steering_supported false" + ); + } + + /// Test 2: no `active_run_id` + capability advertised → the bytes on the + /// wire are an `_session/steering` request carrying `sessionId` and + /// `prompt`, and carrying **no** `expectedRunId` (the adapters reject + /// unknown required fields, and there is no run id to report anyway). + #[tokio::test] + async fn acp_steer_request_omits_expected_run_id_and_carries_session_and_prompt() { + let capture = capture_path("acp_shape"); + let mut client = spawn_steer_capture_script( + &capture, + r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"injected"}}"#, + ) + .await; + set_steering_supported(&mut client); + assert!( + client.active_run_id().is_none(), + "precondition: no active_run_id" + ); + + let (written, ack) = run_one_steer(&mut client, &capture).await; + + let written = written.expect("steer request must have been written"); + let msg: serde_json::Value = + serde_json::from_str(&written).expect("written line must be valid JSON"); + assert_eq!( + msg["method"].as_str(), + Some(ACP_STEER_METHOD), + "must use the cross-adapter steer method; wrote: {written}" + ); + assert_eq!(msg["params"]["sessionId"].as_str(), Some("sess-test")); + assert_eq!( + msg["params"]["prompt"][0]["text"].as_str(), + Some("steer body"), + "prompt must carry the steer body as a text block" + ); + assert!( + msg["params"].get("expectedRunId").is_none(), + "_session/steering must not carry expectedRunId; wrote: {written}" + ); + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "injected outcome must ack Success, got {ack:?}" + ); + } + + /// Test 3: goose keeps priority. With both an `active_run_id` and the + /// advertised capability, the goose method wins — `expectedRunId` is + /// strictly more precise about which run is being steered. + #[tokio::test] + async fn goose_transport_wins_when_both_run_id_and_capability_present() { + let capture = capture_path("goose_priority"); + let mut client = + spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await; + set_steering_supported(&mut client); + let update = session_info_update_msg(Some(serde_json::json!("run-77"))); + let _ = client.handle_session_update(&update); + + let (written, ack) = run_one_steer(&mut client, &capture).await; + + let written = written.expect("steer request must have been written"); + let msg: serde_json::Value = + serde_json::from_str(&written).expect("written line must be valid JSON"); + assert_eq!( + msg["method"].as_str(), + Some(GOOSE_STEER_METHOD), + "goose method must win when a run id exists; wrote: {written}" + ); + assert_eq!(msg["params"]["expectedRunId"].as_str(), Some("run-77")); + // A bare `{}` result is a success on the goose transport (goose sends + // no `outcome`) — the OutcomeRejected guard applies only to + // `_session/steering`. + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "goose success result must ack Success, got {ack:?}" + ); + } + + /// Test 7: codex-acp's third outcome, `failed` + /// (`src/AcpExtensions.ts:92`), is a delivery rejection despite being a + /// JSON-RPC success — release the event and fall back. + #[tokio::test] + async fn acp_steer_failed_outcome_acks_outcome_rejected() { + let capture = capture_path("outcome_failed"); + let mut client = spawn_steer_capture_script( + &capture, + r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"failed"}}"#, + ) + .await; + set_steering_supported(&mut client); + + let (_written, ack) = run_one_steer(&mut client, &capture).await; + + match ack { + crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => { + assert_eq!( + outcome, "failed", + "rejected outcome must report what the agent said, unquoted" + ); + } + other => panic!("expected Err(OutcomeRejected), got {other:?}"), + } + } + + /// Test 8: **codex `extMethod` silent-loss regression guard.** codex-acp's + /// ext dispatcher answers unrecognized methods with a bare `{}` — a + /// JSON-RPC *success*, not `-32601` (`src/CodexAcpServer.ts:255-258`). + /// Buzz maps `SteerAck::Success` to `queue.remove_event`, so decoding + /// `{}` as success would delete the user's message with no error, no + /// fallback, and no log. An absent `outcome` must therefore be a + /// rejection, which releases the event and fires cancel+merge. + #[tokio::test] + async fn acp_steer_missing_outcome_acks_outcome_rejected_and_never_drops_event() { + let capture = capture_path("outcome_absent"); + let mut client = + spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await; + set_steering_supported(&mut client); + + let (_written, ack) = run_one_steer(&mut client, &capture).await; + + match ack { + crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => { + assert_eq!( + outcome, "", + "a result with no outcome field must be reported as absent" + ); + } + other => panic!( + "expected Err(OutcomeRejected) for a bare {{}} success — \ + anything else risks dropping the event, got {other:?}" + ), + } + } + + /// Test 5: `injected` renews the hard deadline, so the turn survives past + /// its original one. Mirrors + /// `steer_success_renews_hard_deadline_and_survives_past_original` for + /// the `_session/steering` transport. + /// + /// Timeline: original hard deadline at t≈1s; steer response at t≈0.5s + /// renews it to t≈3.5s; prompt response at t≈1.5s lands inside it. + #[tokio::test] + async fn acp_steer_injected_renews_hard_deadline_and_survives_past_original() { + let script = "sleep 0.5; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"injected\"}}'; \ + sleep 1; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'"; + let mut client = spawn_script(script).await; + set_steering_supported(&mut client); + + let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1); + client.install_steer_rx(steer_rx); + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); + let send_task = tokio::spawn(async move { + steer_tx + .send(crate::pool::SteerRequest { + prompt_blocks: vec!["steer body".into()], + ack_tx, + }) + .await + .expect("steer_tx send should succeed"); + }); + + let idle = std::time::Duration::from_secs(10); + let max_dur = std::time::Duration::from_secs(3); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let result = client + .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur) + .await; + send_task.await.expect("send_task should complete"); + + assert!( + result.is_ok(), + "injected must renew the deadline so the prompt response still lands, got {result:?}" + ); + assert_eq!(result.unwrap()["done"], serde_json::json!(true)); + let ack = ack_rx.await.expect("ack must be received"); + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "injected must ack Success, got {ack:?}" + ); + } + + /// Test 6: **red/green for the no-renewal rule.** `startedNewTurn` means + /// the turn Buzz was steering had already ended and the adapter began a + /// fresh, detached one. It acks `Success` (the message WAS delivered, so + /// the event must not be redelivered) but must NOT renew the hard + /// deadline — that clock belongs to a turn which is already settled. + /// + /// Same timeline as the `injected` test, so the only difference is the + /// outcome string: original hard deadline at t≈1s, steer response at + /// t≈0.5s, prompt response at t≈1.5s. With renewal the prompt response + /// would land and this returns `Ok`; without renewal the original + /// deadline fires first and we get `HardTimeout`. + #[tokio::test] + async fn acp_steer_started_new_turn_acks_success_without_renewing_hard_deadline() { + let script = "sleep 0.5; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"startedNewTurn\"}}'; \ + sleep 1; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'"; + let mut client = spawn_script(script).await; + set_steering_supported(&mut client); + + let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1); + client.install_steer_rx(steer_rx); + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); + let send_task = tokio::spawn(async move { + steer_tx + .send(crate::pool::SteerRequest { + prompt_blocks: vec!["steer body".into()], + ack_tx, + }) + .await + .expect("steer_tx send should succeed"); + }); + + let idle = std::time::Duration::from_secs(10); + let max_dur = std::time::Duration::from_secs(3); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let result = client + .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur) + .await; + send_task.await.expect("send_task should complete"); + + // The original deadline must still fire — renewal here would extend + // the clock on a turn the adapter has already finished. + assert!( + matches!(result, Err(AcpError::HardTimeout { .. })), + "startedNewTurn must NOT renew the hard deadline, so the original \ + one must still fire; got {result:?}" + ); + // Delivery still succeeded, so the withheld event must be dropped + // rather than released — hence Success, not an Err. + let ack = ack_rx.await.expect("ack must be received"); + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "startedNewTurn is a delivery success, got {ack:?}" + ); + } + + /// Test 4 (companion to the existing + /// `native_steer_with_no_active_run_id_acks_expected_run_id_missing`): + /// no run id AND no advertised capability means nothing is written at + /// all. This is the gate that keeps a steer off the wire for adapters + /// that never implemented either method. + #[tokio::test] + async fn steer_writes_nothing_when_no_run_id_and_capability_absent() { + let capture = capture_path("no_transport"); + let mut client = + spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await; + assert!(!client.steering_supported(), "precondition: not advertised"); + assert!( + client.active_run_id().is_none(), + "precondition: no active_run_id" + ); + + let (written, ack) = run_one_steer(&mut client, &capture).await; + + assert!( + written.is_none(), + "no transport available must write nothing; wrote: {written:?}" + ); + match ack { + crate::pool::SteerAck::Err(crate::pool::SteerError::ExpectedRunIdMissing) => {} + other => panic!("expected Err(ExpectedRunIdMissing), got {other:?}"), + } + } + // ── Goose usage notification integration ────────────────────────────── /// Build a `_goose/unstable/session/update` JSON-RPC notification. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14b..dab61be30a0 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -240,7 +240,7 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] pub relay_url: String, - #[arg(long, env = "BUZZ_PRIVATE_KEY")] + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] pub private_key: String, /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. @@ -423,6 +423,12 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MODEL")] pub model: Option, + /// Title for the agent's ACP sessions, passed out-of-band in `session/new` + /// `_meta`. Adapters that recognize it name the session after this value; + /// others ignore it. Never enters the prompt. + #[arg(long, env = "BUZZ_ACP_SESSION_TITLE")] + pub session_title: Option, + /// Permission mode for agents that support `session/set_config_option` /// with `configId: "mode"` (e.g. `claude-agent-acp`). /// @@ -522,6 +528,9 @@ pub struct Config { pub memory_enabled: bool, /// Desired LLM model ID. Applied after every `session_new_full()`. pub model: Option, + /// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`. + /// `None` when unset or when the configured value sanitized to empty. + pub session_title: Option, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, /// Inbound author gate mode. @@ -554,6 +563,68 @@ pub struct Config { pub base_prompt_content: Option, } +/// Maximum length, in characters, of a session title sent to the adapter. +const SESSION_TITLE_MAX_CHARS: usize = 80; + +/// Normalize a configured session title into something safe to hand an adapter. +/// +/// Control characters are dropped, runs of whitespace collapse to a single +/// space, and the result is trimmed and capped at +/// [`SESSION_TITLE_MAX_CHARS`]. Returns `None` when nothing printable is left. +/// +/// Buzz is the only guard here: Codex's own `normalize_thread_name` merely +/// trims, so an unbounded display name would be persisted verbatim into its +/// thread store. +fn sanitize_session_title(raw: &str) -> Option { + let collapsed = raw + .split_whitespace() + .map(|word| word.chars().filter(|c| !c.is_control()).collect::()) + .filter(|word| !word.is_empty()) + .collect::>() + .join(" "); + // Truncate by chars, not bytes, so a multi-byte name can't be cut mid-UTF-8. + let title: String = collapsed + .chars() + .take(SESSION_TITLE_MAX_CHARS) + .collect::() + .trim_end() + .to_string(); + if title.is_empty() { + None + } else { + Some(title) + } +} + +/// Separator between the agent name and the channel in a composed title. +/// U+00B7 MIDDLE DOT, spaces on both sides. +const SESSION_TITLE_SEPARATOR: &str = " · "; + +/// Compose a per-session title as `Agent · #channel`. +/// +/// One agent in five channels gets five sessions; a bare agent name would show +/// five identical rows in the adapter's thread list. Only the channel part is +/// truncated to fit [`SESSION_TITLE_MAX_CHARS`], so the agent name always +/// 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 { + let Some(channel) = channel_name.and_then(sanitize_session_title) else { + return agent.to_string(); + }; + // Reserve the separator and the `#` sigil alongside the agent name. + let reserved = agent.chars().count() + SESSION_TITLE_SEPARATOR.chars().count() + 1; + let channel: String = channel + .chars() + .take(SESSION_TITLE_MAX_CHARS.saturating_sub(reserved)) + .collect::() + .trim_end() + .to_string(); + if channel.is_empty() { + return agent.to_string(); + } + format!("{agent}{SESSION_TITLE_SEPARATOR}#{channel}") +} + /// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars. fn validate_allowlist(entries: &[String]) -> Result, ConfigError> { let mut validated = HashSet::new(); @@ -605,7 +676,12 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .next() .expect("rsplit always yields at least one element"); let lower = basename.to_ascii_lowercase(); - let stem = lower.strip_suffix(".exe").unwrap_or(&lower); + // Windows resolves commands through `.exe` binaries and npm's `.cmd`/`.bat` + // shims; all three name the same runtime identity. + let stem = [".exe", ".cmd", ".bat"] + .iter() + .find_map(|extension| lower.strip_suffix(extension)) + .unwrap_or(&lower); stem.chars() .map(|character| match character { ' ' | '_' => '-', @@ -623,6 +699,25 @@ fn default_agent_args(command: &str) -> Option> { } } +/// Per-runtime environment defaults applied when Buzz owns the agent process. +/// +/// Mirrors [`default_agent_args`]: keyed on the normalized command identity, +/// with the merge (in `AcpClient::spawn`) giving explicit persona env and +/// inherited parent env precedence over these defaults. +/// +/// Hermes: ACP hosts supply session MCP servers explicitly through +/// `session/new`, but Hermes otherwise starts every profile-configured MCP +/// server before it responds to `initialize` — which can exhaust the host's +/// startup budget (see block/buzz#3355). Skip that unrelated global startup +/// by default; an operator or persona can still opt back in by setting the +/// variable explicitly. +pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'static str)] { + match normalize_agent_command_identity(command).as_str() { + "hermes" | "hermes-agent" | "hermes-acp" => &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], + _ => &[], + } +} + /// Build the `CODEX_CONFIG` environment variable that enables full outbound /// network access in Codex's macOS Seatbelt sandbox. /// @@ -992,6 +1087,10 @@ impl Config { typing_enabled: !args.no_typing, memory_enabled: args.memory && !args.no_memory, model, + session_title: args + .session_title + .as_deref() + .and_then(sanitize_session_title), permission_mode: args.permission_mode, respond_to: args.respond_to, respond_to_allowlist, @@ -1361,6 +1460,7 @@ mod tests { typing_enabled: true, memory_enabled: true, model: None, + session_title: None, permission_mode: PermissionMode::BypassPermissions, respond_to: RespondTo::Anyone, respond_to_allowlist: HashSet::new(), @@ -1513,6 +1613,15 @@ mod tests { "claude-code" ); assert_eq!(normalize_agent_command_identity("Goose.EXE"), "goose"); + // Windows npm shims resolve to `.cmd`/`.bat` wrappers. + assert_eq!( + normalize_agent_command_identity(r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd"), + "hermes-acp" + ); + assert_eq!( + normalize_agent_command_identity(r"C:\Tools\Hermes\HERMES-AGENT.BAT"), + "hermes-agent" + ); // Non-ASCII must not panic. assert_eq!(normalize_agent_command_identity("my-agënt"), "my-agënt"); // Edge cases: empty, whitespace-only, bare separators. @@ -1522,6 +1631,30 @@ mod tests { assert_eq!(normalize_agent_command_identity("///"), ""); } + #[test] + fn default_agent_env_recognizes_hermes_identities() { + for command in [ + "hermes", + "hermes-agent", + "hermes-acp", + "/opt/hermes/bin/hermes-acp", + r"C:\Users\test\bin\HERMES_ACP.EXE", + r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd", + ] { + assert_eq!( + default_agent_env(command), + &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], + "unexpected env defaults for {command}" + ); + } + for command in ["goose", "codex-acp", "claude-agent-acp", "buzz-agent", ""] { + assert!( + default_agent_env(command).is_empty(), + "non-Hermes command must have no env defaults: {command}" + ); + } + } + #[test] fn strips_legacy_acp_arg_case_insensitively() { assert_eq!( @@ -2706,4 +2839,93 @@ channels = "ALL" assert!(MAX_TURN_DURATION_CEILING_SECS < u64::MAX - 100); } } + + #[test] + fn sanitize_session_title_collapses_whitespace_and_strips_control_chars() { + assert_eq!( + sanitize_session_title(" Fizz\t\tthe\n Bot\u{7} "), + Some("Fizz the Bot".to_string()) + ); + } + + #[test] + fn sanitize_session_title_returns_none_when_nothing_printable_remains() { + assert_eq!(sanitize_session_title(" \n\t "), None); + assert_eq!(sanitize_session_title(""), None); + assert_eq!(sanitize_session_title("\u{1}\u{2}"), None); + } + + #[test] + fn sanitize_session_title_caps_length_without_splitting_multibyte_chars() { + let raw = "\u{1f41d}".repeat(SESSION_TITLE_MAX_CHARS + 10); + let title = sanitize_session_title(&raw).expect("emoji title survives sanitizing"); + assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS); + assert!(title.chars().all(|c| c == '\u{1f41d}')); + } + + #[test] + fn sanitize_session_title_does_not_leave_a_trailing_space_after_the_cap() { + // The cap lands mid-word, so trimming must not leave a dangling space. + let raw = format!("{} tail", "a".repeat(SESSION_TITLE_MAX_CHARS - 1)); + let title = sanitize_session_title(&raw).expect("title survives sanitizing"); + assert_eq!(title, "a".repeat(SESSION_TITLE_MAX_CHARS - 1)); + } + + #[test] + fn compose_session_title_qualifies_the_agent_name_with_the_channel() { + assert_eq!( + compose_session_title("Fizz", Some("buzz-dev")), + "Fizz · #buzz-dev" + ); + } + + #[test] + fn compose_session_title_falls_back_to_bare_agent_name_without_a_channel() { + assert_eq!(compose_session_title("Fizz", None), "Fizz"); + assert_eq!(compose_session_title("Fizz", Some(" ")), "Fizz"); + } + + #[test] + fn compose_session_title_truncates_the_channel_and_keeps_the_agent_name() { + let channel = "c".repeat(200); + let title = compose_session_title("Fizz", Some(&channel)); + assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS); + assert!(title.starts_with("Fizz · #c")); + } + + #[test] + fn compose_session_title_drops_the_channel_when_the_agent_name_fills_the_cap() { + let agent = "a".repeat(SESSION_TITLE_MAX_CHARS); + assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); + } + + /// 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] + fn secret_env_args_hide_their_values_in_help() { + use clap::CommandFactory; + + const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"]; + + let cmd = CliArgs::command(); + let violations: Vec = cmd + .get_arguments() + .filter_map(|arg| { + let env_key = arg.get_env()?; + let env_name = env_key.to_string_lossy().to_uppercase(); + let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat)); + if is_secret && !arg.is_hide_env_values_set() { + Some(env_name) + } else { + None + } + }) + .collect(); + + assert!( + violations.is_empty(), + "Found secret-bearing env args without hide_env_values=true. \ + Add `hide_env_values = true` to each: {violations:?}" + ); + } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0230ea0875f..d63f720c651 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1140,9 +1140,7 @@ fn any_respawn_in_flight(crash_history: &[SlotCircuit]) -> bool { /// Result of a background respawn task. struct RespawnResult { index: usize, - /// Tuple: (initialized client, protocol version, supports_goose_steer). - /// The third element is always `true` — the supervisor uses - /// try-and-tolerate for the steer extension. + /// Tuple: (initialized client, protocol version, agent name). result: Result<(AcpClient, u32, String)>, } @@ -1535,6 +1533,7 @@ async fn tokio_main() -> Result<()> { turn_liveness_interval: Duration::from_secs(config.turn_liveness_secs), dedup_mode: config.dedup_mode, system_prompt: config.system_prompt.clone(), + session_title: config.session_title.clone(), team_instructions: config.team_instructions.clone(), base_prompt: if config.no_base_prompt { None @@ -2227,18 +2226,18 @@ async fn tokio_main() -> Result<()> { owner_cache.get(), ); if let Some(signal) = signal { - // Try-and-tolerate fork: when the mode - // wants a Steer, attempt the non-cancelling - // path first for any agent. On accept, + // 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 `-32601 method_not_found` - // from agents that don't implement the - // extension), fall through to the universal - // cancel+merge `Steer` signal so the event - // still reaches the agent. + // (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, @@ -2418,14 +2417,26 @@ async fn tokio_main() -> Result<()> { event_id, ack, })) => { - // Goose-native steer attempt resolved. Locked semantics - // (Eva + Max + Perci, unanimous on Option X): + // Mid-turn steer attempt resolved (either transport: + // `_goose/unstable/session/steer` or `_session/steering`). + // Locked semantics (Eva + Max + Perci, unanimous on Option X): // // Success // The agent received the steer via the non-cancelling // path. Drop the withheld event so normal dispatch // never redelivers it. // + // Also covers `_session/steering`'s `startedNewTurn` + // outcome: the message was delivered, but into a fresh + // turn because the one being steered had already + // finished. Delivery is what this arm keys on, so the + // event is still dropped. The read loop deliberately + // does NOT renew its hard deadline in that case (the + // awaited turn is settled), while + // `extend_in_flight_deadline` below still applies — + // the agent really is running more work, so the + // channel's in-flight budget should reflect it. + // // Err(_) where the write never landed (Transport / // ExpectedRunIdMissing): // Delivery state of the underlying message is "never @@ -2433,6 +2444,16 @@ async fn tokio_main() -> Result<()> { // queue front AND issue the cancel+merge fallback so // the message still reaches the agent. // + // Err(OutcomeRejected { .. }) + // A `_session/steering` request returned a JSON-RPC + // success whose `outcome` was not `injected` or + // `startedNewTurn` (codex's `failed`, an unknown value, + // or a bare `{}` with no `outcome` at all). The steer + // did not land, so this is treated exactly like a write + // that never happened: release withheld AND fire the + // cancel+merge fallback. Handled by the catch-all + // `Err(_)` arm below. + // // Err(AgentError { code: -32601, .. }) // The agent returned method_not_found — it does not // implement the steer extension. Release withheld AND @@ -2489,9 +2510,9 @@ async fn tokio_main() -> Result<()> { Ok(pool::SteerAck::Err(pool::SteerError::AgentError { .. })) => { (true, false, false) } - // Transport / ExpectedRunIdMissing: write never landed. - // Release and fire the cancel+merge fallback so the - // message still reaches the agent. + // Transport / ExpectedRunIdMissing / OutcomeRejected: the + // steer did not land. Release and fire the cancel+merge + // fallback so the message still reaches the agent. Ok(pool::SteerAck::Err(_)) => (true, false, true), Ok(pool::SteerAck::PromptCompletedNeutral) => (true, false, false), Err(_recv_err) => (true, false, false), @@ -2925,15 +2946,15 @@ fn dispatch_pending( let ctx_clone = Arc::clone(ctx); let agent_index = agent.index; - // Goose-native non-cancelling steer seam: snapshot capability before - // the agent moves into `run_prompt_task`, and install the per-turn - // steer receiver on the read loop so the main loop's mode-gate fork + // Mid-turn non-cancelling steer seam: install the per-turn steer + // receiver on the read loop so the main loop's mode-gate fork // (see the `if accepted && queue.is_channel_in_flight(...)` block // in the relay event branch of the main `select!` loop) can drive // it via the matching sender stored in `TaskMeta.steer_tx`. - // Install the steer channel for every prompt task — the supervisor - // uses try-and-tolerate: it attempts the steer for any agent and - // treats `-32601 method_not_found` as "fall back to cancel+merge". + // Installed for every prompt task: the read loop picks the steer + // transport at write time from `active_run_id` and the agent's + // advertised `_session/steering` capability, and acks + // `ExpectedRunIdMissing` (→ cancel+merge) when it has neither. let (tx, rx) = tokio::sync::mpsc::channel::(1); agent.acp.install_steer_rx(rx); let steer_tx = Some(tx); @@ -3782,7 +3803,8 @@ async fn initialize_agent_pool( .and_then(|info| info.get("name")) .and_then(|v| v.as_str()) .unwrap_or("unknown"), - "agent initialized — non-cancelling steer enabled (try-and-tolerate)" + steering_supported = acp.steering_supported(), + "agent initialized" ); acp.observe( "agent_initialized", @@ -4026,7 +4048,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> { // so shutdown() runs on all paths (success, error, timeout). let protocol_result = tokio::time::timeout(MODELS_TIMEOUT, async { let init = client.initialize().await?; - let session = client.session_new_full(&cwd, vec![], None).await?; + let session = client.session_new_full(&cwd, vec![], None, None).await?; Ok::<_, acp::AcpError>((init, session)) }) .await; @@ -4178,6 +4200,18 @@ fn build_mcp_servers(config: &Config) -> Vec { }); } } + // Forward the agent's display name so dev-mcp can use it as the git + // author name instead of the raw npub. Read from the process env + // rather than Config: this is a pass-through of a contract owned + // upstream, and absent simply means dev-mcp falls back to the npub. + if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") { + if !display_name.is_empty() { + env.push(EnvVar { + name: "BUZZ_ACP_DISPLAY_NAME".into(), + value: display_name, + }); + } + } env }, }] @@ -4973,6 +5007,7 @@ mod build_mcp_servers_tests { typing_enabled: true, memory_enabled: false, model: None, + session_title: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, respond_to_allowlist: std::collections::HashSet::new(), @@ -5036,6 +5071,60 @@ mod build_mcp_servers_tests { assert!(!has_auth_tag, "empty BUZZ_AUTH_TAG should not be forwarded"); } + #[test] + fn test_display_name_set_is_forwarded_to_mcp_server() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("BUZZ_ACP_DISPLAY_NAME", "Duncan"); + let config = test_config(); + let servers = build_mcp_servers(&config); + std::env::remove_var("BUZZ_ACP_DISPLAY_NAME"); + + let entry = servers[0] + .env + .iter() + .find(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"); + assert_eq!( + entry.map(|e| e.value.as_str()), + Some("Duncan"), + "a set display name should reach the MCP server verbatim" + ); + } + + #[test] + fn test_display_name_unset_omits_the_key_entirely() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("BUZZ_ACP_DISPLAY_NAME"); + let config = test_config(); + let servers = build_mcp_servers(&config); + + // Absent, not empty-valued: dev-mcp distinguishes the two and only + // falls back to the npub when the key is missing or blank. + assert!( + !servers[0] + .env + .iter() + .any(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"), + "unset display name should not add the key" + ); + } + + #[test] + fn test_display_name_empty_omits_the_key_entirely() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("BUZZ_ACP_DISPLAY_NAME", ""); + let config = test_config(); + let servers = build_mcp_servers(&config); + std::env::remove_var("BUZZ_ACP_DISPLAY_NAME"); + + assert!( + !servers[0] + .env + .iter() + .any(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"), + "empty display name should not be forwarded" + ); + } + #[test] fn empty_mcp_command_returns_no_servers() { let mut config = test_config(); @@ -5139,6 +5228,7 @@ mod error_outcome_emission_tests { typing_enabled: true, memory_enabled: false, model: None, + session_title: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, respond_to_allowlist: HashSet::new(), diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index cc537f86830..038f8a714c1 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -32,7 +32,7 @@ use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, }; -use crate::config::{DedupMode, PermissionMode}; +use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; use crate::queue::{ CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, @@ -309,10 +309,13 @@ pub enum ControlSignal { /// for that — only a function parameter pass-through. /// /// If `active_run_id` is `None` at write time (no `session/update` seen yet -/// — e.g. agents that never emit run-id metadata), the steer cannot form a -/// valid `expectedRunId` and the read loop acks -/// [`SteerError::ExpectedRunIdMissing`]. The main loop maps this to the -/// "Err-before-pending" bucket: no withhold/mark was established at +/// — e.g. agents that never emit run-id metadata), the goose-native method +/// cannot form a valid `expectedRunId`, and the read loop falls back to the +/// cross-adapter `_session/steering` method when the agent advertised +/// `_meta.steering.supported` at `initialize`. That method takes no run id, so +/// no freshness concern applies to it. When neither transport is available the +/// read loop acks [`SteerError::ExpectedRunIdMissing`]. The main loop maps that +/// to the "Err-before-pending" bucket: no withhold/mark was established at /// `pool::send_steer` time because the request was rejected before any /// write, so the watcher only needs to release nothing and fall back to the /// universal `ControlSignal::Steer` cancel+merge path. @@ -326,7 +329,8 @@ pub struct SteerRequest { pub ack_tx: tokio::sync::oneshot::Sender, } -/// Why a goose-native steer failed. +/// Why a mid-turn steer failed, on either transport +/// (`_goose/unstable/session/steer` or `_session/steering`). /// /// String and integer fields are intentionally `Debug`-only — read by /// `tracing` macros in the main loop's `PoolEvent::SteerAck` arm via @@ -349,14 +353,28 @@ pub enum SteerError { /// Transport-level failure: write error, read EOF, JSON-RPC framing /// violation, etc. The string carries the underlying `AcpError`'s display. Transport(String), - /// At steer-write time `AcpClient::active_run_id` was `None`, so the - /// read loop couldn't form a valid `expectedRunId`. The read loop drops - /// the request without writing anything; the main loop should release - /// any withheld event and fall back to the universal cancel+merge + /// At steer-write time neither steer transport was available: no + /// `expectedRunId` (`AcpClient::active_run_id` was `None`, so the + /// goose-native method could not be formed) and the agent did not + /// advertise the cross-adapter `_session/steering` extension. The read + /// loop drops the request without writing anything; the main loop should + /// release any withheld event and fall back to the universal cancel+merge /// `ControlSignal::Steer` path. This is in the same "Err-before-pending" /// bucket as `Transport` write failures: no in-process state was /// established, so no in-process cleanup is needed. ExpectedRunIdMissing, + /// A `_session/steering` request returned a JSON-RPC *success* whose + /// `outcome` was not one of the two recognized delivery outcomes + /// (`injected`, `startedNewTurn`) — including `failed` (codex-acp) and a + /// missing `outcome` entirely. `outcome` carries what the agent actually + /// reported, for logs. + /// + /// The steer did NOT land, so the main loop must release the withheld + /// event and fire the cancel+merge fallback — exactly like a write that + /// never happened. Treating an unrecognized success as delivery would + /// drop the user's message: codex-acp answers unrecognized extension + /// methods with a bare `{}` success rather than `-32601`. + OutcomeRejected { outcome: String }, /// The read loop never got to dispatch the steer because the prompt /// completed first. Delivery state for the underlying message is /// unknown after prompt completion — the main loop must treat this as @@ -369,7 +387,7 @@ pub enum SteerError { PromptCompleted, } -/// Outcome of a goose-native steer, sent from the read loop back to the +/// Outcome of a mid-turn steer, sent from the read loop back to the /// main loop's ack watcher. #[derive(Debug)] pub enum SteerAck { @@ -490,6 +508,9 @@ 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. + pub session_title: Option, pub team_instructions: Option, pub heartbeat_prompt: Option, /// Base prompt content, or `None` if `--no-base-prompt` was passed. @@ -795,6 +816,49 @@ const CONTROL_CANCEL_GRACE: Duration = Duration::from_secs(5); /// Timeout for permission-mode requests (`session/set_config_option` with `configId: "mode"`). const PERMISSION_MODE_TIMEOUT: Duration = Duration::from_secs(5); +/// Placeholder [`fetch_channel_info`] substitutes when a channel's metadata +/// event carries no `name` tag. Not a real channel name — consumers that need +/// an identifying name must treat it as absent. +const UNKNOWN_CHANNEL_NAME: &str = "unknown"; + +/// Channel-derived inputs for a new session — `(is_dm, title_channel)` — from +/// **one** metadata resolve. +/// +/// Both new-session consumers need the same lookup: the canvas block skips DMs +/// (and fails closed when the channel type can't be determined), and the +/// session title is qualified with the channel name. Resolving once is +/// load-bearing rather than tidy: [`ChannelInfoResolver`] caches only `Some`, +/// so two calls against an unresolvable channel pay the whole +/// [`fetch_channel_info`] retry sequence twice — two `CONTEXT_FETCH_TIMEOUT` +/// attempts plus `CONTEXT_FETCH_RETRY_DELAY` each, in front of `session/new`, +/// precisely when the relay is already degraded. +/// +/// `title_channel` is `None` whenever the channel can't usefully identify the +/// session: an unresolved channel, a DM (no meaningful name), or the literal +/// `"unknown"` that [`fetch_channel_info`] substitutes for a metadata event +/// with no `name` tag. Composing that sentinel would title every unnamed +/// channel identically (`Agent · #unknown`) — reintroducing the collision the +/// suffix exists to remove, while naming a channel something it isn't. The +/// startup cache already refuses `channel_type == "unknown"` for the same +/// reason. +/// +/// Renames do not retitle live sessions, and a **channel** rename is stickier +/// than an agent rename: `invalidate_channel` drops the session but not the +/// resolver's cached entry, so a renamed channel keeps its old suffix until the +/// process restarts. An agent rename lands on the next spawn (the desktop +/// restart badge covers it — see `spawn_config_hash`). +async fn resolve_new_session_channel_context( + channel_info: &ChannelInfoResolver, + channel_id: Uuid, +) -> (bool, Option) { + let Some(info) = channel_info.resolve(channel_id).await else { + return (true, None); + }; + let is_dm = info.channel_type == "dm"; + let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name); + (is_dm, title_channel) +} + /// Create a new ACP session via `session_new_full()`, populate model capabilities /// on the agent (first session only), and apply `desired_model` if set. /// @@ -806,6 +870,7 @@ async fn create_session_and_apply_model( ctx: &PromptContext, agent_core: Option<&str>, agent_canvas: Option<&str>, + channel_name: Option<&str>, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -825,6 +890,11 @@ async fn create_session_and_apply_model( agent_canvas, ); + let session_title = ctx + .session_title + .as_deref() + .map(|agent_name| compose_session_title(agent_name, channel_name)); + let resp = agent .acp .session_new_full( @@ -835,6 +905,7 @@ async fn create_session_and_apply_model( agent.protocol_version, combined_system_prompt.as_deref(), ), + session_title.as_deref(), ) .await?; @@ -1427,18 +1498,20 @@ pub async fn run_prompt_task( // 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; + // 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; if let PromptSource::Channel(cid) = &source { let is_new_channel_session = !agent.state.sessions.contains_key(cid); - if is_new_channel_session && !agent.state.canvas_sections.contains_key(cid) { - // Resolve DM status: prefer the startup cache, lazy-fetch as fallback. - // Unknown → treat as DM (fail-closed). - let is_dm = ctx - .channel_info - .resolve(*cid) - .await - .map(|ci| ci.channel_type == "dm") - .unwrap_or(true); - if !is_dm { + let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); + let needs_title = is_new_channel_session && ctx.session_title.is_some(); + if needs_canvas || needs_title { + let (is_dm, resolved_channel) = + resolve_new_session_channel_context(&ctx.channel_info, *cid).await; + title_channel = resolved_channel; + // 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)); } @@ -1470,12 +1543,16 @@ pub async fn run_prompt_task( if let Some(sid) = agent.state.sessions.get(cid) { (sid.clone(), false) } else { - // Create new session with model application. + // 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. match create_session_and_apply_model( &mut agent, &ctx, agent_core.as_deref(), agent_canvas.as_deref(), + title_channel.as_deref(), ) .await { @@ -1523,7 +1600,7 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None, None).await { + match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await { Ok(sid) => { tracing::info!( target: "pool::session", @@ -1820,6 +1897,18 @@ pub async fn run_prompt_task( None => prompt_sections.iter().map(String::as_str).collect(), }; + // Turn start, labelled exactly as `log_stop_reason` labels the end, so a + // log reads as start/stop pairs. Purely observational: an unpaired start is + // the only durable evidence that a turn was entered and never returned, and + // without it a stalled agent and an agent nobody woke leave identical logs — + // zero completions either way, so anything reading them afterwards has to + // guess which happened. + tracing::info!( + target: "pool::prompt", + "turn starting for {}", + prompt_label(&source) + ); + // When control_rx is Some (channel tasks), wrap the prompt in select! so // the main loop can cancel, interrupt, or rotate it. Heartbeats // (control_rx=None) take the simple await path — they are not controllable. @@ -2268,7 +2357,7 @@ pub(crate) async fn fetch_channel_info( } let channel_type = crate::relay::channel_type_from_tags(tags); Some(PromptChannelInfo { - name: name.unwrap_or("unknown").to_string(), + name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(), channel_type, }) } @@ -3054,12 +3143,19 @@ fn classify_control_cancel_failure( } } -/// Log a stop reason at the appropriate tracing level. -fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { - let label = match source { +/// How a turn's source is named in the `pool::prompt` log lines. +/// +/// 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::Heartbeat => "heartbeat".to_string(), - }; + } +} + +/// Log a stop reason at the appropriate tracing level. +fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { + let label = prompt_label(source); match stop_reason { StopReason::EndTurn => { tracing::info!(target: "pool::prompt", "turn complete for {label}: end_turn"); @@ -3781,6 +3877,9 @@ mod tests { #[test] fn test_framed_system_prompt_both_present_carries_both_headers() { + // Also the regression guard against #2372: the session title travels + // out of band in `_meta.sessionTitle`, so this exact-bytes assertion is + // what pins the framing against a `[Session]` section reappearing here. let framed = framed_system_prompt("/", Some("base text"), Some("persona text")) .expect("both present yields Some"); assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text"); @@ -5280,6 +5379,7 @@ mod tests { turn_liveness_interval: Duration::ZERO, dedup_mode: DedupMode::Drop, system_prompt: None, + session_title: None, team_instructions: None, heartbeat_prompt: None, base_prompt: None, @@ -5617,4 +5717,142 @@ mod tests { "timestamp must not use +00:00 offset" ); } + + // ── new-session channel context (one resolve, two consumers) ───────────── + + /// A [`ChannelInfoResolver`] whose lazy REST fallback is served by a local + /// HTTP server, plus a counter of the requests that actually reached it. + /// Counting real requests is the point: the composition tests are pure and + /// cannot see duplicated I/O. + async fn counting_resolver( + response: serde_json::Value, + ) -> ( + ChannelInfoResolver, + std::sync::Arc, + tokio::task::JoinHandle<()>, + ) { + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test HTTP server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = std::sync::Arc::new(AtomicUsize::new(0)); + let server_requests = requests.clone(); + let body = response.to_string(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut buf = vec![0; 8192]; + let _ = socket.read(&mut buf).await; + server_requests.fetch_add(1, Ordering::SeqCst); + let response = format!( + "HTTP/1.1 200 OK\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 rest = crate::relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + ( + ChannelInfoResolver::new(std::collections::HashMap::new(), rest), + requests, + server, + ) + } + + fn channel_metadata_response(id: Uuid, tags: &[[&str; 2]]) -> serde_json::Value { + let mut event_tags = vec![json!(["d", id.to_string()])]; + event_tags.extend(tags.iter().map(|[k, v]| json!([k, v]))); + json!([{ "tags": event_tags }]) + } + + /// A normal channel yields a non-DM (canvas allowed) and its name for the + /// title suffix — and the second consumer reads it from cache, not the wire. + #[tokio::test] + async fn test_new_session_channel_context_qualifies_a_normal_channel() { + use std::sync::atomic::Ordering; + + let id = Uuid::new_v4(); + let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); + let (resolver, requests, server) = counting_resolver(response).await; + + let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + assert!(!is_dm, "a stream channel is not a DM"); + assert_eq!(title_channel.as_deref(), Some("buzz-dev")); + assert_eq!(requests.load(Ordering::SeqCst), 1); + + let (_, again) = resolve_new_session_channel_context(&resolver, id).await; + assert_eq!(again.as_deref(), Some("buzz-dev")); + assert_eq!( + requests.load(Ordering::SeqCst), + 1, + "a resolved channel is cached — no second lookup" + ); + server.abort(); + } + + /// A DM carries no useful name, so it gets the bare agent title (and no + /// canvas section). + #[tokio::test] + async fn test_new_session_channel_context_leaves_a_dm_unqualified() { + let id = Uuid::new_v4(); + let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]); + let (resolver, _requests, server) = counting_resolver(response).await; + + let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + assert!(is_dm); + assert_eq!( + title_channel, None, + "a DM name must never reach the session title" + ); + server.abort(); + } + + /// The `"unknown"` placeholder `fetch_channel_info` substitutes for a + /// metadata event with no `name` tag is not a channel name: qualifying with + /// it would title every unnamed channel `Agent · #unknown`. + #[tokio::test] + async fn test_new_session_channel_context_treats_the_unknown_name_as_absent() { + let id = Uuid::new_v4(); + let response = channel_metadata_response(id, &[["t", "stream"]]); + let (resolver, _requests, server) = counting_resolver(response).await; + + let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + assert!(!is_dm, "a nameless stream channel is still not a DM"); + assert_eq!( + title_channel, None, + "the `unknown` placeholder must yield a bare title" + ); + server.abort(); + } + + /// An unresolvable channel yields the bare title, fails closed as a DM, and + /// costs exactly ONE `fetch_channel_info` sequence — two attempts, because + /// `fetch_with_retry` retries once. `resolve()` caches only `Some`, so a + /// second resolve for the title would double this in front of `session/new`, + /// exactly when the relay is already degraded. + #[tokio::test] + async fn test_new_session_channel_context_attempts_an_unresolved_channel_once() { + use std::sync::atomic::Ordering; + + let (resolver, requests, server) = counting_resolver(json!([])).await; + + let (is_dm, title_channel) = + resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await; + assert!(is_dm, "an undeterminable channel type must fail closed"); + assert_eq!(title_channel, None, "unresolved channels get a bare title"); + assert_eq!( + requests.load(Ordering::SeqCst), + 2, + "one fetch_channel_info sequence (initial attempt + single retry)" + ); + server.abort(); + } } diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index a4f7abd3b32..8cca9c96f8b 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -85,6 +85,12 @@ pub(crate) struct UsageUpdatePayload { pub context_limit: u64, pub accumulated_input_tokens: u64, pub accumulated_output_tokens: u64, + /// The cache-served subset of `accumulated_input_tokens`. Optional — goose + /// does not send it, and buzz-agent only reports a non-zero value when the + /// provider returned a cache split, so `0` legitimately means either "no + /// cache hits" or "provider reported none". + #[serde(default)] + pub accumulated_cached_input_tokens: u64, pub accumulated_cost: Option, /// Effective model id for this turn. Optional — goose payloads that /// predate this field deserialize cleanly as `None`. @@ -323,12 +329,44 @@ impl UsageTracker { mod tests { use super::*; + /// The camelCase key buzz-agent actually puts on the wire must land on the + /// field. A rename mismatch here would deserialize to the serde default of + /// 0, and every trial would price as if nothing had ever been cached — the + /// exact silent failure this field was added to remove. + #[test] + fn cached_input_tokens_deserialize_from_the_wire_key() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "used": 15_247, + "contextLimit": 0, + "accumulatedInputTokens": 15_091, + "accumulatedOutputTokens": 156, + "accumulatedCachedInputTokens": 5_033, + })) + .expect("payload must deserialize"); + assert_eq!(p.accumulated_cached_input_tokens, 5_033); + assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens); + } + + /// goose does not send the field; its payloads must still deserialize. + #[test] + fn a_payload_without_the_cache_field_defaults_to_zero() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "used": 500, + "contextLimit": 200_000, + "accumulatedInputTokens": 400, + "accumulatedOutputTokens": 100, + })) + .expect("payload must deserialize without the cache field"); + assert_eq!(p.accumulated_cached_input_tokens, 0); + } + fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload { UsageUpdatePayload { used: input + output, context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, + accumulated_cached_input_tokens: 0, accumulated_cost: cost, model: None, } @@ -340,6 +378,7 @@ mod tests { context_limit: 0, accumulated_input_tokens: input, accumulated_output_tokens: output, + accumulated_cached_input_tokens: 0, accumulated_cost: cost, model: None, } @@ -836,6 +875,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, + accumulated_cached_input_tokens: 0, accumulated_cost: cost, model: model.map(str::to_string), } diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 730e87b2e87..ed04daca2cb 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -60,6 +60,11 @@ pub struct RunCtx<'a> { /// Accumulated output tokens across all LLM rounds in this turn, for /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. pub turn_output_tokens: &'a mut Option, + /// The cache-served subset of `turn_input_tokens`, accumulated across all + /// LLM rounds in this turn. Reset to `None` at turn start in `run()`. + /// Consumers price this slice at the provider's cached rate; without it + /// every round of a growing conversation is billed at full price. + pub turn_cached_input_tokens: &'a mut Option, } impl RunCtx<'_> { @@ -78,6 +83,7 @@ impl RunCtx<'_> { // Reset per-turn token accumulators for this prompt. *self.turn_input_tokens = None; *self.turn_output_tokens = None; + *self.turn_cached_input_tokens = None; let mut round = 0u32; // Per-prompt `_Stop` objection count. Bounded per prompt (not per @@ -175,6 +181,17 @@ impl RunCtx<'_> { *self.turn_output_tokens = Some(self.turn_output_tokens.unwrap_or(0).saturating_add(out)); } + // Accumulate the cache-served subset of this turn's input. Tracked + // separately from `turn_input_tokens` rather than subtracted from + // it: the input total must stay inclusive for the handoff gate, + // which cares how much context was sent, not what it cost. + if let Some(cached) = response.cached_input_tokens { + *self.turn_cached_input_tokens = Some( + self.turn_cached_input_tokens + .unwrap_or(0) + .saturating_add(cached), + ); + } if !response.reasoning.is_empty() { wire::send( @@ -679,6 +696,9 @@ pub(crate) fn push_hook_outputs_as_tool_results( provider_id: provider_id.clone(), name: tool_name, arguments: serde_json::json!({}), + // Synthesised locally, so there is no provider wire form to + // preserve. + provider_extra: Default::default(), }], }); history.push(HistoryItem::ToolResult(ToolResult { diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index f3464fb9039..037b67b3cb9 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -184,6 +184,7 @@ pub fn anthropic_thinking_config( fn anthropic_model_supports_xhigh(model: &str) -> bool { model.starts_with("claude-opus-4-7") || model.starts_with("claude-opus-4-8") + || model.starts_with("claude-opus-5") || model.starts_with("claude-sonnet-5") || model.starts_with("claude-fable-5") || model.starts_with("claude-mythos-5") @@ -606,6 +607,7 @@ fn is_adaptive_thinking_model(model: &str) -> bool { model.starts_with("claude-opus-4-6") || model.starts_with("claude-opus-4-7") || model.starts_with("claude-opus-4-8") + || model.starts_with("claude-opus-5") // Sonnet 5.x (any patch/date suffix after "claude-sonnet-5"). || model.starts_with("claude-sonnet-5") // Sonnet 4.6 exactly (not Sonnet 4.5 or earlier — not in the adaptive table). @@ -736,6 +738,13 @@ pub struct Config { /// Thinking/reasoning effort level. `None` = use provider default (no /// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`. pub thinking_effort: Option, + /// Emit Anthropic `cache_control` breakpoints on the stable prefix + /// (tools + system prompt) and the rolling conversation tail. Default on; + /// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Only consulted on Anthropic + /// Messages routes (first-party Anthropic and the DatabricksV2 Claude + /// route) — the Databricks gateway does not auto-cache, so without this the + /// surfaced `cache_read_input_tokens` is structurally always 0. + pub prompt_caching: bool, } impl Config { @@ -831,6 +840,7 @@ impl Config { hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, + prompt_caching: parse_env("BUZZ_AGENT_PROMPT_CACHING", 1u8)? != 0, }; cfg.validate()?; Ok(cfg) @@ -872,6 +882,7 @@ impl Config { hook_servers: HookServers::None, hints_enabled: false, thinking_effort: None, + prompt_caching: false, } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index e141b9860f4..6745dd0f92d 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -100,6 +100,11 @@ struct Session { accumulated_input_tokens: u64, /// Session-cumulative output tokens across all turns. accumulated_output_tokens: u64, + /// Session-cumulative cache-served input tokens across all turns — a subset + /// of `accumulated_input_tokens`, not an addition to it. Emitted alongside + /// it so a consumer can price the cached slice at the provider's discounted + /// rate instead of assuming every input token cost full price. + accumulated_cached_input_tokens: u64, } fn die(msg: String) -> ! { @@ -426,6 +431,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen effective_model: None, accumulated_input_tokens: 0, accumulated_output_tokens: 0, + accumulated_cached_input_tokens: 0, }, ); drop(sessions); @@ -672,6 +678,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender .unwrap_or(&app.cfg.model); let mut turn_input_tokens: Option = None; let mut turn_output_tokens: Option = None; + let mut turn_cached_input_tokens: Option = None; let mut ctx = RunCtx { cfg: &app.cfg, effective_model: effective_model_str, @@ -690,6 +697,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender last_request_history_bytes: &mut last_request_history_bytes, turn_input_tokens: &mut turn_input_tokens, turn_output_tokens: &mut turn_output_tokens, + turn_cached_input_tokens: &mut turn_cached_input_tokens, }; let result = ctx.run(p.prompt).await; if let Some(s) = app.sessions.lock().await.get_mut(&sid) { @@ -722,14 +730,21 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender s.accumulated_output_tokens = s .accumulated_output_tokens .saturating_add(turn_output_tokens.unwrap_or(0)); - Some((s.accumulated_input_tokens, s.accumulated_output_tokens)) + s.accumulated_cached_input_tokens = s + .accumulated_cached_input_tokens + .saturating_add(turn_cached_input_tokens.unwrap_or(0)); + Some(( + s.accumulated_input_tokens, + s.accumulated_output_tokens, + s.accumulated_cached_input_tokens, + )) } else { // Session is gone — the accumulated baseline no longer exists, so // there is nothing correct to emit. Skip the usage notification. None } }; - if let Some((accumulated_in, accumulated_out)) = accumulated { + if let Some((accumulated_in, accumulated_out, accumulated_cached)) = accumulated { wire::send( &wire_tx, goose_session_update( @@ -742,6 +757,11 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender "contextLimit": 0u64, "accumulatedInputTokens": accumulated_in, "accumulatedOutputTokens": accumulated_out, + // A subset of accumulatedInputTokens, not an addition to + // it. Extends goose's usage_update shape; a consumer that + // does not know the field ignores it and prices exactly as + // it did before. + "accumulatedCachedInputTokens": accumulated_cached, "model": effective_model_str, }), ), diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 23cef24e72a..22d3f8b73e6 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use reqwest::Client; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use tokio::sync::Mutex; use tokio::time::Instant; @@ -720,6 +720,12 @@ fn anthropic_body( } } flush(&mut messages, &mut pending); + // Rolling cache breakpoint: mark the tail of the (append-only) conversation + // so the next turn re-reads this whole prefix from cache instead of paying + // full input price for it. See `stamp_rolling_cache_breakpoint`. + if cfg.prompt_caching { + stamp_rolling_cache_breakpoint(&mut messages); + } let tools_json: Vec = tools .iter() .map(|t| { @@ -727,8 +733,19 @@ fn anthropic_body( "name": t.name, "description": t.description, "input_schema": t.input_schema }) }) .collect(); + // Static prefix breakpoint: caching the `system` block caches the whole + // prefix up to and including it — and the prefix order is + // `tools -> system -> messages`, so this single marker caches tools + + // system together. Requires the structured (array) form of `system`; skip + // it for an empty prompt since Anthropic rejects empty text blocks. + let system_value = if cfg.prompt_caching && !system_prompt.is_empty() { + json!([{ "type": "text", "text": system_prompt, + "cache_control": { "type": "ephemeral" } }]) + } else { + json!(system_prompt) + }; let mut body = json!({ "model": effective_model, "max_tokens": cfg.max_output_tokens, - "system": system_prompt, "messages": messages }); + "system": system_value, "messages": messages }); if let Some(e) = effort { let (thinking, output_config) = crate::config::anthropic_thinking_config(effective_model, e, cfg.max_output_tokens); @@ -745,6 +762,41 @@ fn anthropic_body( body } +/// Attach ephemeral `cache_control` markers to the tail of the conversation so +/// the next turn re-reads the whole prior prefix from cache (~0.1x input price) +/// rather than re-billing it as fresh input. Anthropic caches the prefix up to +/// and including each marked block. +/// +/// We mark the last content block of the last *two* messages, not just the +/// final one. Each Anthropic breakpoint walks back at most 20 content blocks to +/// find a prior cache entry, and one agentic turn can append ~17 blocks at the +/// default `max_parallel_tools` (1 assistant text + N `tool_use` + +/// N `tool_result`). With only a tail marker, consecutive breakpoints sit a +/// full turn apart, which slips past the 20-block window as soon as parallelism +/// rises or a turn carries extra blocks — and the miss is silent. Marking the +/// last two messages halves the gap (to ~N+1 blocks), keeping a live cache +/// entry comfortably within reach. Uses 2 of the 4 allowed breakpoints; the +/// static `system` marker is the third. +/// +/// A no-op for messages whose content is empty or whose tail block is not a +/// JSON object. +fn stamp_rolling_cache_breakpoint(messages: &mut [Value]) { + let n = messages.len(); + // The two most-recently-appended messages (the current turn's tool results + // and the assistant turn before them). `checked_sub` + `flatten` skips the + // second index when there is only one message. + for idx in [n.checked_sub(1), n.checked_sub(2)].into_iter().flatten() { + if let Some(block) = messages[idx] + .get_mut("content") + .and_then(Value::as_array_mut) + .and_then(|c| c.last_mut()) + .and_then(Value::as_object_mut) + { + block.insert("cache_control".into(), json!({ "type": "ephemeral" })); + } + } +} + fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec { content .iter() @@ -796,11 +848,24 @@ fn openai_body( let calls: Vec = tool_calls .iter() .map(|c| { - json!({ - "id": c.provider_id, "type": "function", - "function": { "name": c.name, - "arguments": serde_json::to_string(&c.arguments) - .unwrap_or_else(|_| "{}".into()) } }) + let mut call = serde_json::Map::new(); + call.insert("id".into(), json!(c.provider_id)); + call.insert("type".into(), json!("function")); + // Provider-owned fields go back beside `function`, + // which is where the provider put them. Position is + // load-bearing, not cosmetic: Gemini rejects a + // `thoughtSignature` nested inside `function{}` with + // the same 400 it gives for one that is missing. + for (k, v) in &c.provider_extra { + call.insert(k.clone(), v.clone()); + } + call.insert( + "function".into(), + json!({ "name": c.name, + "arguments": serde_json::to_string(&c.arguments) + .unwrap_or_else(|_| "{}".into()) }), + ); + Value::Object(call) }) .collect(); msg.insert("tool_calls".into(), Value::Array(calls)); @@ -967,13 +1032,53 @@ fn is_responses_required_error(body: &str) -> bool { || b.contains("use the responses api") } +/// OpenAI-family code names that appear as their own segment in a Databricks v2 +/// endpoint name (the GPT-5 launch aliases). The `gpt` family itself is matched +/// separately by segment prefix so `gpt`, `gpt5`, and the `gpt` of a split +/// `gpt-5` all qualify. +const DATABRICKS_V2_OPENAI_CODE_NAMES: &[&str] = &["sol", "luna", "terra"]; + +/// Anthropic (Claude) family and release code names that appear as their own +/// segment in a Databricks v2 endpoint name — the `claude` prefix, the family +/// names (`opus`, `sonnet`, `haiku`), and the release code names (`mythos`, +/// `fable`). Getting a Claude model onto the Anthropic Messages route is what +/// lets it carry a `cache_control` breakpoint; an endpoint that matches none of +/// these falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt +/// caching is structurally impossible and the discount is silently lost. +const DATABRICKS_V2_CLAUDE_NAMES: &[&str] = + &["claude", "opus", "sonnet", "haiku", "mythos", "fable"]; + +/// Split a Databricks v2 endpoint name into its lowercase alphanumeric segments, +/// breaking on any non-alphanumeric delimiter (`-`, `_`, `.`, `/`, …). E.g. +/// `Databricks-Claude-Opus-5` -> `["databricks", "claude", "opus", "5"]`. +fn model_name_segments(model: &str) -> Vec { + model + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route { - // Databricks v2 catalog names currently identify OpenAI-shaped GPT-5 - // models and Anthropic-shaped Claude models by these substrings. - let lower = model.to_ascii_lowercase(); - if lower.contains("gpt-5") || lower.contains("gpt5") { + // The v2 catalog exposes no family field, so the wire format is inferred + // from the endpoint name. Discovery deliberately keeps arbitrary custom + // aliases, so we match whole name *segments* rather than raw substrings: a + // substring test would misroute unrelated names — `consolidated-llama` + // (`sol`), `terraform-coder` (`terra`), `corpus-reranker`/`octopus-model` + // (`opus`) — onto a wire whose request shape their backend can't parse, + // turning a caching optimization into a hard request/parse failure. Segment + // matching still accepts real prefixed names like `goose-opus-5`. + let segments = model_name_segments(model); + let has_named_segment = + |names: &[&str]| segments.iter().any(|seg| names.contains(&seg.as_str())); + // `gpt` family: any segment beginning with `gpt` — covers `gpt`, `gpt5`, and + // the `gpt` segment of a split `gpt-5`, without matching mid-word. + let is_gpt_family = segments.iter().any(|seg| seg.starts_with("gpt")); + // OpenAI is checked before Claude so a name carrying both markers resolves + // to the OpenAI wire (preserving the prior `gpt-5`-first precedence). + if is_gpt_family || has_named_segment(DATABRICKS_V2_OPENAI_CODE_NAMES) { DatabricksV2Route::OpenAiResponses - } else if lower.contains("claude") { + } else if has_named_segment(DATABRICKS_V2_CLAUDE_NAMES) { DatabricksV2Route::AnthropicMessages } else { DatabricksV2Route::MlflowChatCompletions @@ -1028,10 +1133,15 @@ fn parse_responses(v: Value) -> Result { let args: Value = serde_json::from_str(raw).map_err(|e| { AgentError::Llm(format!("function_call.arguments not valid JSON: {e}")) })?; + // No passthrough on this route: `responses_body` replays a + // function call as `{call_id, name, arguments}` and the Responses + // API asks for nothing else, so an empty map keeps the request + // byte-identical to before. tool_calls.push(make_tool_call( str_field(item, "call_id"), str_field(item, "name"), args, + Default::default(), )?); } Some("reasoning") => { @@ -1079,11 +1189,18 @@ fn parse_responses(v: Value) -> Result { }; let input_tokens = sum_usage(&v, &["input_tokens"]); let output_tokens = sum_usage(&v, &["output_tokens"]); + // The Responses API nests the cache split under `input_tokens_details`. + let cached_input_tokens = usage_first( + &v, + &["cache_read_input_tokens"], + &[("input_tokens_details", "cached_tokens")], + ); Ok(LlmResponse { text, tool_calls, stop, input_tokens, + cached_input_tokens, output_tokens, reasoning, }) @@ -1131,19 +1248,70 @@ fn anthropic_input_tokens(v: &Value) -> Option { ) } -/// Input-token total for OpenAI Chat Completions and Databricks responses. -/// OpenAI's `prompt_tokens` is already inclusive. Databricks uses the same -/// `prompt_tokens` wire field but ALSO reports Anthropic-style cache fields -/// alongside it, so we sum them; the cache fields are simply absent (and -/// contribute 0) for vanilla OpenAI. +/// Input-token total for OpenAI Chat Completions and Databricks MLflow-route +/// responses. `prompt_tokens` is already the inclusive input total on both, so +/// it is read alone and never summed with the cache fields. +/// +/// Vanilla OpenAI nests the cache split under `prompt_tokens_details` and +/// `prompt_tokens` includes it. The Databricks MLflow route reports the split +/// with the flat Anthropic spelling (`cache_read_input_tokens`) *alongside* an +/// already-inclusive `prompt_tokens` — so summing double-counts. Verified on +/// `databricks-glm-5-2` (2026-07-28): `prompt_tokens 13320`, +/// `cache_read_input_tokens 13312`, `completion_tokens 30`, `total_tokens +/// 13350`; since `prompt_tokens + completion_tokens == total_tokens`, the 13312 +/// cached tokens are contained in the 13320, not additional to it. Summing gave +/// 26632 — nearly double — inflating both the context-budget gate and cost. +/// +/// This differs from Anthropic's native route (see [`anthropic_input_tokens`]), +/// where `input_tokens` genuinely EXCLUDES the cache fields and must be summed. +/// The two never collide here: the router sends `claude*` models to the +/// Anthropic route, so `parse_openai` only ever sees inclusive `prompt_tokens`. fn openai_chat_input_tokens(v: &Value) -> Option { - sum_usage( + sum_usage(v, &["prompt_tokens"]) +} + +/// First present value among `usage.` and `usage..` pairs. +/// +/// Cache counts are the one usage figure providers do not agree on the shape of. +/// Anthropic puts `cache_read_input_tokens` flat on `usage`; OpenAI nests the +/// same quantity one level down, under `prompt_tokens_details` on +/// `/chat/completions` and `input_tokens_details` on `/responses`. [`sum_usage`] +/// only reads flat keys, which is why the OpenAI split was invisible for so +/// long: `prompt_tokens` is already inclusive, so the *total* was right and +/// nothing looked broken while the discount silently went unclaimed. +/// +/// Returns the first candidate that resolves, not a sum — these are alternative +/// spellings of one number, so adding them would double-count on Databricks, +/// which reports both shapes. +fn usage_first(v: &Value, flat: &[&str], nested: &[(&str, &str)]) -> Option { + let usage = v.get("usage")?; + for f in flat { + if let Some(n) = usage.get(*f).and_then(Value::as_u64) { + return Some(n); + } + } + for (outer, leaf) in nested { + if let Some(n) = usage + .get(*outer) + .and_then(|o| o.get(*leaf)) + .and_then(Value::as_u64) + { + return Some(n); + } + } + None +} + +/// Cache-read tokens for an OpenAI Chat Completions response. +/// +/// `prompt_tokens_details.cached_tokens` is where vanilla OpenAI reports it. +/// The flat Anthropic spelling is checked first for Databricks, which routes +/// Anthropic models through an OpenAI-shaped envelope. +fn openai_chat_cached_tokens(v: &Value) -> Option { + usage_first( v, - &[ - "prompt_tokens", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ], + &["cache_read_input_tokens"], + &[("prompt_tokens_details", "cached_tokens")], ) } @@ -1151,6 +1319,76 @@ fn str_field(v: &Value, key: &str) -> String { v.get(key).and_then(Value::as_str).unwrap_or("").to_owned() } +/// Append `part` to `buf` on its own line, ignoring empties. +fn push_part(buf: &mut String, part: &str) { + if part.is_empty() { + return; + } + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(part); +} + +/// Split an OpenAI-shaped `message.content` into `(text, reasoning)`. +/// +/// Standard OpenAI sends a string. Several models on the Databricks MLflow route +/// — Gemini, Qwen35, gpt-oss — send an array of typed blocks instead, and +/// `as_str()` yields nothing for an array, so their entire answer was being +/// discarded: no error, no warning, just a turn that looked like the model had +/// said nothing. `parse_anthropic` already walks a block array; this gives +/// `parse_openai` the same tolerance. +fn openai_content_parts(content: Option<&Value>) -> (String, String) { + let mut text = String::new(); + let mut reasoning = String::new(); + match content { + Some(Value::String(s)) => text.push_str(s), + Some(Value::Array(blocks)) => { + for b in blocks { + match b.get("type").and_then(Value::as_str) { + Some("text") => push_part(&mut text, &str_field(b, "text")), + Some("reasoning") => match b.get("summary").and_then(Value::as_array) { + // Gemini nests the prose one level down under `summary`. + Some(summary) => { + for s in summary { + push_part(&mut reasoning, &str_field(s, "text")); + } + } + None => push_part(&mut reasoning, &str_field(b, "text")), + }, + // An untyped block carrying text is still the model talking; + // treating it as text loses nothing and keeps one more + // provider out of the silent-empty-answer failure mode. + _ => push_part(&mut text, &str_field(b, "text")), + } + } + } + _ => {} + } + (text, reasoning) +} + +/// Make `provider_id` unique across one assistant turn's tool calls. +/// +/// Gemini returns the function name as the id, so two parallel calls to the same +/// function arrive sharing one id — and that id is what pairs a `role:"tool"` +/// result back to its call, leaving two results indistinguishable. Rewriting is +/// safe because both halves of that pairing are re-emitted from this same value; +/// the provider never sees its original id again. +fn dedupe_provider_ids(calls: &mut [ToolCall]) { + let mut seen: BTreeSet = BTreeSet::new(); + for c in calls.iter_mut() { + if seen.contains(&c.provider_id) { + let mut n = 2; + while seen.contains(&format!("{}-{n}", c.provider_id)) { + n += 1; + } + c.provider_id = format!("{}-{n}", c.provider_id); + } + seen.insert(c.provider_id.clone()); + } +} + fn parse_anthropic(v: Value) -> Result { let stop = map_stop(v.get("stop_reason").and_then(Value::as_str)); let mut tool_calls = Vec::new(); @@ -1173,10 +1411,12 @@ fn parse_anthropic(v: Value) -> Result { reasoning.push_str(t); } } + // Anthropic's replay shape is fully modelled, so nothing to keep. Some("tool_use") => tool_calls.push(make_tool_call( str_field(b, "id"), str_field(b, "name"), b.get("input").cloned().unwrap_or(Value::Null), + Default::default(), )?), _ => {} } @@ -1184,11 +1424,15 @@ fn parse_anthropic(v: Value) -> Result { } let input_tokens = anthropic_input_tokens(&v); let output_tokens = sum_usage(&v, &["output_tokens"]); + // Anthropic reports the cache split flat on `usage`. Note this is already + // part of `input_tokens` above, which sums it in deliberately. + let cached_input_tokens = usage_first(&v, &["cache_read_input_tokens"], &[]); Ok(LlmResponse { text, tool_calls, stop, input_tokens, + cached_input_tokens, output_tokens, reasoning, }) @@ -1204,17 +1448,23 @@ fn parse_openai(v: Value) -> Result { let msg = choice .get("message") .ok_or_else(|| AgentError::Llm("missing message".into()))?; - let text = str_field(msg, "content"); + let (text, block_reasoning) = openai_content_parts(msg.get("content")); // DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the // message object. Prefer `reasoning_content` (DeepSeek's field name); fall - // back to `reasoning` (some other providers). Both are absent for standard - // OpenAI responses, which leaves this empty without any special-casing. + // back to `reasoning` (some other providers), and last to reasoning blocks + // found inside `content`. All three are absent for standard OpenAI + // responses, which leaves this empty without any special-casing. let reasoning = { let rc = str_field(msg, "reasoning_content"); - if rc.is_empty() { + let rc = if rc.is_empty() { str_field(msg, "reasoning") } else { rc + }; + if rc.is_empty() { + block_reasoning + } else { + rc } }; let mut tool_calls = Vec::new(); @@ -1226,26 +1476,45 @@ fn parse_openai(v: Value) -> Result { let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}"); let args: Value = serde_json::from_str(raw) .map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?; + // Everything on the wire object we do not model, kept for replay. + let extra = tc + .as_object() + .map(|o| { + o.iter() + .filter(|(k, _)| !matches!(k.as_str(), "id" | "type" | "function")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); tool_calls.push(make_tool_call( str_field(tc, "id"), str_field(f, "name"), args, + extra, )?); } } + dedupe_provider_ids(&mut tool_calls); let input_tokens = openai_chat_input_tokens(&v); let output_tokens = sum_usage(&v, &["completion_tokens"]); + let cached_input_tokens = openai_chat_cached_tokens(&v); Ok(LlmResponse { text, tool_calls, stop, input_tokens, + cached_input_tokens, output_tokens, reasoning, }) } -fn make_tool_call(id: String, name: String, args: Value) -> Result { +fn make_tool_call( + id: String, + name: String, + args: Value, + provider_extra: Map, +) -> Result { if id.is_empty() || name.is_empty() { return Err(AgentError::Llm("tool_call missing id or name".into())); } @@ -1262,6 +1531,7 @@ fn make_tool_call(id: String, name: String, args: Value) -> Result Value { + json!({"choices": [{"finish_reason": "tool_calls", "message": { + "role": "assistant", + "content": [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "weighing it"}]}, + {"type": "text", "text": "391"} + ], + "tool_calls": [{ + "id": "get_weather", "type": "function", "thoughtSignature": "SIG-A", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"} + }] + }}]}) + } + + #[test] + fn parse_openai_reads_text_out_of_a_block_array() { + // Before this, `as_str()` on the array yielded "" and the model's answer + // was discarded with no error at all. + let r = parse_openai(gemini_choice()).unwrap(); + assert_eq!(r.text, "391"); + assert_eq!(r.reasoning, "weighing it"); + } + + #[test] + fn parse_openai_still_reads_a_plain_string_content() { + let v = json!({"choices": [{"finish_reason": "stop", "message": { + "role": "assistant", "content": "plain"}}]}); + let r = parse_openai(v).unwrap(); + assert_eq!(r.text, "plain"); + assert_eq!(r.reasoning, ""); + } + + #[test] + fn parse_openai_keeps_unmodelled_tool_call_fields() { + let r = parse_openai(gemini_choice()).unwrap(); + let extra = &r.tool_calls[0].provider_extra; + assert_eq!(extra.get("thoughtSignature"), Some(&json!("SIG-A"))); + // `id`/`type`/`function` are modelled, so they must not be duplicated + // into the passthrough — they would be re-emitted twice. + assert!(!extra.contains_key("id")); + assert!(!extra.contains_key("type")); + assert!(!extra.contains_key("function")); + } + + #[test] + fn openai_body_replays_the_signature_beside_function_not_inside_it() { + // Position is what the gateway checks: nested inside `function{}` it is + // rejected with the same 400 as a missing signature. + let r = parse_openai(gemini_choice()).unwrap(); + let history = vec![HistoryItem::Assistant { + text: r.text.clone(), + tool_calls: r.tool_calls.clone(), + }]; + let body = openai_body( + &cfg(Provider::DatabricksV2), + "sys", + &history, + &[], + "databricks-gemini-3-6-flash", + None, + ); + let call = &body["messages"][1]["tool_calls"][0]; + assert_eq!(call["thoughtSignature"], json!("SIG-A")); + assert!(call["function"].get("thoughtSignature").is_none()); + assert_eq!(call["function"]["name"], json!("get_weather")); + } + + #[test] + fn parse_openai_makes_duplicate_tool_call_ids_unique() { + // Gemini returns the function name as the id, so parallel calls to one + // function collide and their results become indistinguishable. + let v = json!({"choices": [{"finish_reason": "tool_calls", "message": { + "role": "assistant", "content": "", + "tool_calls": [ + {"id": "get_weather", "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}}, + {"id": "get_weather", "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Rome\"}"}} + ]}}]}); + let r = parse_openai(v).unwrap(); + assert_eq!(r.tool_calls[0].provider_id, "get_weather"); + assert_eq!(r.tool_calls[1].provider_id, "get_weather-2"); + } + #[test] fn parse_openai_uses_prompt_tokens() { let v = serde_json::json!({ @@ -3493,20 +4034,34 @@ mod tests { } #[test] - fn parse_openai_databricks_sums_cache_fields() { - // Databricks uses the OpenAI chat wire format (prompt_tokens) but also - // reports Anthropic-style cache fields; the inclusive total sums them. + fn parse_openai_databricks_prompt_tokens_already_inclusive() { + // Databricks' MLflow route uses the OpenAI chat wire format + // (prompt_tokens) but ALSO reports the flat Anthropic-style + // cache_read_input_tokens. prompt_tokens is already inclusive of that + // slice, so the total is prompt_tokens alone — summing double-counts. + // Values are the live databricks-glm-5-2 response (2026-07-28), where + // prompt_tokens + completion_tokens == total_tokens proves inclusivity. let v = serde_json::json!({ "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], "usage": { - "prompt_tokens": 200, - "completion_tokens": 4, - "total_tokens": 204, - "cache_read_input_tokens": 800, - "cache_creation_input_tokens": 0 + "prompt_tokens": 13320, + "completion_tokens": 30, + "total_tokens": 13350, + "cache_read_input_tokens": 13312, + "prompt_tokens_details": {"cached_tokens": 13312} } }); - assert_eq!(parse_openai(v).unwrap().input_tokens, Some(1000)); + let r = parse_openai(v).unwrap(); + assert_eq!( + r.input_tokens, + Some(13320), + "prompt_tokens is the inclusive total" + ); + assert_eq!(r.cached_input_tokens, Some(13312)); + assert!( + r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap(), + "the cached slice is a subset of the input total" + ); } #[test] @@ -3517,6 +4072,115 @@ mod tests { assert_eq!(parse_openai(v).unwrap().input_tokens, None); } + #[test] + fn parse_openai_reads_nested_cached_tokens() { + // The shape vanilla OpenAI actually returns, captured from a live + // /chat/completions probe on gpt-5.6-luna: `prompt_tokens` is already + // inclusive and the cache split is nested one level down. Reading only + // flat keys left the discount unclaimed while the total looked correct, + // which is why this went unnoticed. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "OK"}}], + "usage": { + "prompt_tokens": 5229, + "completion_tokens": 4, + "total_tokens": 5233, + "prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 5226, + "cache_write_tokens": 0} + } + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.input_tokens, Some(5229), "total must stay inclusive"); + assert_eq!(r.cached_input_tokens, Some(5226)); + } + + #[test] + fn parse_openai_cache_write_round_reports_zero_cached() { + // First request of a cold prefix: the provider writes the cache and + // serves nothing from it. `Some(0)` not `None` — the split was reported, + // it was simply zero, and a consumer must be able to tell the two apart. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "OK"}}], + "usage": { + "prompt_tokens": 5229, + "completion_tokens": 4, + "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 5226} + } + }); + assert_eq!(parse_openai(v).unwrap().cached_input_tokens, Some(0)); + } + + #[test] + fn parse_openai_no_cache_detail_is_none() { + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], + "usage": {"prompt_tokens": 123, "completion_tokens": 4} + }); + assert_eq!(parse_openai(v).unwrap().cached_input_tokens, None); + } + + #[test] + fn parse_openai_prefers_flat_anthropic_spelling_over_nested() { + // Databricks reports both shapes for the same quantity. Take one, never + // the sum, or the cached slice double-counts. cache_read (800) is a + // subset of the inclusive prompt_tokens (1000), as it must be. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 4, + "cache_read_input_tokens": 800, + "prompt_tokens_details": {"cached_tokens": 800} + } + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.input_tokens, Some(1000)); + assert_eq!(r.cached_input_tokens, Some(800)); + assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap()); + } + + #[test] + fn parse_anthropic_reports_cache_read_as_cached() { + // Anthropic's `input_tokens` EXCLUDES cached, so the inclusive total is + // a sum -- but the cached slice must still be a subset of that total. + let v = serde_json::json!({ + "stop_reason": "end_turn", + "content": [{"type": "text", "text": "hi"}], + "usage": { + "input_tokens": 100, + "output_tokens": 7, + "cache_read_input_tokens": 900, + "cache_creation_input_tokens": 50 + } + }); + let r = parse_anthropic(v).unwrap(); + assert_eq!(r.input_tokens, Some(1050)); + assert_eq!(r.cached_input_tokens, Some(900)); + assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap()); + } + + #[test] + fn parse_responses_reads_nested_cached_tokens() { + // The Responses API nests the same figure under a different key than + // /chat/completions does. + let v = serde_json::json!({ + "status": "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}] + }], + "usage": { + "input_tokens": 4000, + "output_tokens": 9, + "input_tokens_details": {"cached_tokens": 3584} + } + }); + let r = parse_responses(v).unwrap(); + assert_eq!(r.input_tokens, Some(4000)); + assert_eq!(r.cached_input_tokens, Some(3584)); + } + #[test] fn parse_responses_uses_input_tokens() { let v = serde_json::json!({ diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index fa8815df50a..9ae125a0b76 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -52,6 +52,31 @@ const PASSTHROUGH_ENV: &[&str] = &[ "GIT_ASKPASS", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", + // Proxy — on a host whose only route out is a CONNECT proxy, dropping + // these does not degrade the tools, it blinds them: apt, curl, pip and git + // all connect directly instead, and the egress firewall resets the socket. + // The agent then reports "Connection reset by peer" and concludes the + // environment has no network, which is indistinguishable in the transcript + // from a task that is genuinely offline. + // + // Both cases are needed. curl and git read the lowercase spellings, most + // Go and Python tooling reads the uppercase ones, and libcurl deliberately + // ignores uppercase HTTP_PROXY (CGI ambiguity), so keeping only one form + // silently breaks half the toolchain. + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + // TLS trust — a proxy that terminates TLS presents its own CA, and an + // image whose trust store does not carry it fails every https fetch with a + // verification error. Same class of failure as the proxy vars: the parent + // was configured correctly and the child could not see it. + "SSL_CERT_FILE", + "SSL_CERT_DIR", // Buzz identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then // removes it from its own env (children never see it). BUZZ_PRIVATE_KEY // and BUZZ_RELAY_URL are kept for the buzz CLI. BUZZ_AUTH_TAG is a @@ -61,6 +86,11 @@ const PASSTHROUGH_ENV: &[&str] = &[ "BUZZ_PRIVATE_KEY", "BUZZ_RELAY_URL", "BUZZ_AUTH_TAG", + // Agent display name — dev-mcp uses it as the git author name. On the + // Desktop path this arrives via the wire `mcpServers[].env` declaration + // (which wins here anyway); the allowlist entry covers ACP clients that + // spawn buzz-agent without declaring it. + "BUZZ_ACP_DISPLAY_NAME", ]; // Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what @@ -1010,6 +1040,41 @@ mod content_tests { fn passthrough_includes_buzz_owner_attestation() { assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG")); } + + #[test] + fn passthrough_carries_proxy_configuration_to_tools() { + // On a proxy-only host this is the difference between an agent that can + // install a package and one that reports the network is down. Both + // spellings: libcurl ignores uppercase HTTP_PROXY, and Go/Python + // tooling largely ignores the lowercase set. + for var in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + ] { + assert!( + PASSTHROUGH_ENV.contains(&var), + "{var} must survive env_clear() or every MCP tool loses the proxy" + ); + } + } + + #[test] + fn passthrough_carries_tls_trust_to_tools() { + // A TLS-terminating proxy presents its own CA; without these the child + // rejects every https fetch even though the proxy itself is reachable. + for var in ["SSL_CERT_FILE", "SSL_CERT_DIR"] { + assert!( + PASSTHROUGH_ENV.contains(&var), + "{var} must survive env_clear() or https fails inside tools" + ); + } + } use rmcp::model::Content; #[cfg(windows)] diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index d29e975e03b..a3d48a7cf12 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -1,5 +1,5 @@ use serde::Deserialize; -use serde_json::Value; +use serde_json::{Map, Value}; /// Byte-equivalent charged to the handoff/context-pressure gate for a single /// image tool result. The gate maps bytes to tokens at 1 byte/token (see @@ -93,6 +93,13 @@ impl HistoryItem { + serde_json::to_vec(&c.arguments) .map(|b| b.len()) .unwrap_or(0) + // `provider_extra` (e.g. a Gemini + // `thoughtSignature`) is re-serialized into + // every replayed call, so it counts toward the + // request body and the context-pressure gate. + + serde_json::to_vec(&c.provider_extra) + .map(|b| b.len()) + .unwrap_or(0) }) .sum::() } @@ -108,6 +115,17 @@ pub struct ToolCall { pub provider_id: String, pub name: String, pub arguments: Value, + /// Fields the provider put on the tool call that we do not model, kept so + /// the assistant turn can be replayed the way it arrived. + /// + /// Gemini on the Databricks MLflow route returns a `thoughtSignature` per + /// call and *requires* it echoed back: replaying without it fails the whole + /// request with `Function call is missing a thought_signature in functionCall + /// parts`. For an agent loop that lands on the very first tool call, so the + /// model is unusable without this. Carrying whatever we did not model, + /// rather than naming that one field, means the next provider with an opaque + /// per-call token needs no change here. + pub provider_extra: Map, } #[derive(Debug, Clone)] @@ -139,6 +157,17 @@ pub struct LlmResponse { /// tokens, so reading it alone would undercount). Used to gate handoff on /// the real token budget rather than a byte estimate. pub input_tokens: Option, + /// The portion of `input_tokens` the provider served from its prompt cache, + /// or `None` when the response reported no cache split. Providers bill this + /// slice at a large discount (roughly 10x for both OpenAI and Anthropic), + /// so a consumer that prices all of `input_tokens` at the full rate + /// *overstates* cost — by a lot on an append-only agent loop, where most of + /// each request is a prefix the provider already has. + /// + /// This is a subset of `input_tokens`, never an addition to it: every + /// provider we speak to reports an inclusive input total, so adding this + /// would double-count. + pub cached_input_tokens: Option, /// Output tokens the provider reported for this request, or `None` if the /// response carried no usage. Used to accumulate per-turn output counts /// for NIP-AM metric publishing. @@ -342,6 +371,39 @@ mod tests { assert!(item.estimated_bytes() >= 3_118_884); } + #[test] + fn assistant_size_counts_provider_extra() { + // A Gemini `thoughtSignature` rides the wire on every replayed call, so + // both size measures must see it — otherwise `truncate_history` and the + // handoff gate under-count and let the real request exceed the budget. + let mut extra = Map::new(); + extra.insert("thoughtSignature".into(), Value::String("S".repeat(500))); + let with_extra = HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "id".into(), + name: "t".into(), + arguments: Value::Null, + provider_extra: extra, + }], + }; + let without_extra = HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "id".into(), + name: "t".into(), + arguments: Value::Null, + provider_extra: Map::new(), + }], + }; + assert!(with_extra.estimated_bytes() > without_extra.estimated_bytes() + 500); + assert_eq!( + with_extra.estimated_bytes(), + with_extra.context_pressure_bytes(), + "provider_extra is text, so both measures must agree" + ); + } + #[test] fn text_content_size_is_identical_for_both_measures() { // Only images diverge; text must size the same under both paths. diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf062..40699459fc0 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -57,6 +57,8 @@ buzz users get # your own profile buzz users get --pubkey # single user buzz users get --pubkey --pubkey # batch (max 200) buzz users set-presence --status online +buzz users set-status --text "heads down on the CLI" --emoji "🚀" +buzz users set-status --clear # remove your status # DMs buzz dms open --pubkey @@ -133,6 +135,7 @@ stored rules in `validation_error` so an owner can remove and repair them. | | `set-profile` | Update your profile | | | `presence` | Get presence status | | | `set-presence` | Set presence status | +| | `set-status` | Set or clear your NIP-38 profile status | | `workflows` | `list` | List workflows | | | `get` | Get workflow definition | | | `create` | Create a workflow | diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 4b7257aba76..77234b7faab 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -87,7 +87,7 @@ export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output | `channels:read` | ✅ | `channels list`, `channels get`, `channels members` | | `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` | | `users:read` | ✅ | `users get`, `users presence` | -| `users:write` | ✅ | `users set-profile`, `users set-presence` | +| `users:write` | ✅ | `users set-profile`, `users set-presence`, `users set-status` | | `files:read` | ✅ | — | | `files:write` | ✅ | — | | `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` | @@ -331,6 +331,20 @@ buzz users set-presence --status online | jq . buzz users set-presence --status away | jq . buzz users set-presence --status offline | jq . # Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge + +# users set-status — NIP-38 kind:30315 on the d:general coordinate +buzz users set-status --text "reviewing PRs" --emoji "🔍" | jq . +buzz users set-status --text "no emoji this time" | jq . + +# users set-status — emoji-only status (intentional: text is blank, emoji is kept) +buzz users set-status --text "" --emoji "🎶" | jq . + +# users set-status --clear — removes the status (empty content, d:general only) +buzz users set-status --clear | jq . + +# --clear is mutually exclusive with --text/--emoji +buzz users set-status --clear --text "nope" 2>&1; echo "exit: $?" +# Expected: exit 1 — clap conflict error ``` ### 6.8 Channel Members (add/remove require admin:channels) @@ -606,3 +620,4 @@ buzz channels delete --channel "$FORUM_ID" | jq . | 59 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 | | 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit | | 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound | +| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 | diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9e..353e6717448 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -272,12 +272,17 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result/media/`, so the relay URL's own path is stripped before + // the `/media/` check rather than assuming the relay owns the root. + let relay_prefix = relay.path().trim_end_matches('/'); + let media_path = parsed + .path() + .strip_prefix(relay_prefix) + .unwrap_or(parsed.path()); + let Some(sha256_ext) = media_path.strip_prefix("/media/") else { return Err(CliError::Usage( "media URL must point at a /media/ path".to_string(), )); @@ -287,8 +292,6 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result/media/`, so both the sha shorthand and a full URL have to + /// work against a relay URL that carries a path. + #[test] + fn media_url_handles_a_relay_served_under_a_base_path() { + let hash = "a".repeat(64); + assert_eq!( + media_url_from_input("https://relay.example/relay", &format!("{hash}.jpg")).unwrap(), + format!("https://relay.example/relay/media/{hash}.jpg"), + "sha shorthand resolves under the prefix" + ); + assert_eq!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://relay.example/relay/media/{hash}.jpg") + ) + .unwrap(), + format!("https://relay.example/relay/media/{hash}.jpg"), + "a full prefixed URL is accepted, not rejected as non-media" + ); + assert!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://relay.example/relay/media-evil/{hash}.jpg") + ) + .is_err(), + "the /media/ requirement still holds inside the prefix" + ); + assert!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://evil.example/relay/media/{hash}.jpg") + ) + .is_err(), + "origin pinning still holds under a prefix" + ); + } + #[test] fn media_url_accepts_only_same_relay_media_urls() { let hash = "a".repeat(64); diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9d..f5a0bee8799 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -304,6 +304,22 @@ pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), C Ok(()) } +/// Set user status — sign and submit a NIP-38 kind:30315 user status event. +/// +/// Uses the `d:general` coordinate that the desktop client reads for the +/// profile status line. A blank `text` with no `emoji` clears the status. +pub async fn cmd_set_status( + client: &BuzzClient, + text: &str, + emoji: Option<&str>, +) -> Result<(), CliError> { + let builder = buzz_sdk::build_user_status(text, emoji).map_err(crate::validate::sdk_err)?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + pub async fn dispatch( cmd: crate::UsersCmd, client: &BuzzClient, @@ -331,6 +347,16 @@ pub async fn dispatch( } UsersCmd::Presence { pubkeys } => cmd_get_presence(client, &pubkeys).await, UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await, + UsersCmd::SetStatus { text, emoji, clear } => { + // `--clear` is mutually exclusive with `--text`/`--emoji`: publish the + // empty `d:general` event that clients read as "no status". + let (text, emoji) = if clear { + ("", None) + } else { + (text.as_deref().unwrap_or_default(), emoji.as_deref()) + }; + cmd_set_status(client, text, emoji).await + } } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082df..74656258040 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -82,11 +82,11 @@ struct Cli { relay: String, /// Nostr private key (hex or nsec). This is the CLI's identity. - #[arg(long, env = "BUZZ_PRIVATE_KEY")] + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] private_key: Option, /// NIP-OA auth tag JSON (owner attestation). Injected into every signed event. - #[arg(long, env = "BUZZ_AUTH_TAG")] + #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, /// Output format: 'json' (default, full fields) or 'compact' (reduced fields). @@ -838,6 +838,19 @@ pub enum UsersCmd { #[arg(long, value_enum)] status: PresenceStatus, }, + /// Set your user status (NIP-38 kind:30315 — the "status" line on your profile) + #[command(name = "set-status")] + SetStatus { + /// Status text (required unless --clear) + #[arg(long, required_unless_present = "clear")] + text: Option, + /// Optional emoji shown before the status text + #[arg(long)] + emoji: Option, + /// Remove your status entirely + #[arg(long, conflicts_with_all = ["text", "emoji"])] + clear: bool, + }, } #[derive(Subcommand)] @@ -1803,6 +1816,30 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn set_status_clear_rejects_text_and_emoji() { + for extra in [["--text", "busy"], ["--emoji", "🎶"]] { + let args = ["buzz", "users", "set-status", "--clear"] + .into_iter() + .chain(extra); + assert!( + Cli::try_parse_from(args).is_err(), + "--clear must conflict with {}", + extra[0] + ); + } + } + + #[test] + fn set_status_requires_text_or_clear() { + assert!(Cli::try_parse_from(["buzz", "users", "set-status"]).is_err()); + assert!( + Cli::try_parse_from(["buzz", "users", "set-status", "--emoji", "🎶"]).is_err(), + "--emoji alone must not imply a status" + ); + assert!(Cli::try_parse_from(["buzz", "users", "set-status", "--clear"]).is_ok()); + } + #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ @@ -1924,7 +1961,13 @@ mod tests { ); assert_eq!( names(&cmd, "users"), - vec!["get", "presence", "set-presence", "set-profile"] + vec![ + "get", + "presence", + "set-presence", + "set-profile", + "set-status" + ] ); assert_eq!( names(&cmd, "workflows"), @@ -2011,7 +2054,7 @@ mod tests { ("repos", 4), ("social", 7), ("upload", 1), - ("users", 4), + ("users", 5), ("workflows", 8), ]; @@ -2032,4 +2075,46 @@ mod tests { ); } } + + /// Collect all args (recursing into subcommands) whose env var name looks + /// like a credential but does NOT have `hide_env_values` set. + fn collect_unhidden_secret_args(cmd: &clap::Command) -> Vec<(String, String)> { + const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"]; + + let mut violations: Vec<(String, String)> = Vec::new(); + + for arg in cmd.get_arguments() { + if let Some(env_key) = arg.get_env() { + let env_name = env_key.to_string_lossy().to_uppercase(); + let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat)); + if is_secret && !arg.is_hide_env_values_set() { + violations.push((cmd.get_name().to_string(), env_name)); + } + } + } + + for sub in cmd.get_subcommands() { + violations.extend(collect_unhidden_secret_args(sub)); + } + + violations + } + + /// 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] + fn secret_env_args_hide_their_values_in_help() { + let cmd = Cli::command(); + let violations = collect_unhidden_secret_args(&cmd); + assert!( + violations.is_empty(), + "Found secret-bearing env args without hide_env_values=true. \ + Add `hide_env_values = true` to each:\n{}", + violations + .iter() + .map(|(cmd, env)| format!(" command={cmd:?} env={env:?}")) + .collect::>() + .join("\n") + ); + } } diff --git a/crates/buzz-core/Cargo.toml b/crates/buzz-core/Cargo.toml index 489df360dd5..c55225adf60 100644 --- a/crates/buzz-core/Cargo.toml +++ b/crates/buzz-core/Cargo.toml @@ -11,6 +11,7 @@ description = "Core types, event verification, and filter matching for Buzz" test-utils = [] [dependencies] +base64 = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-core/src/invite.rs b/crates/buzz-core/src/invite.rs new file mode 100644 index 00000000000..8f557c95aa2 --- /dev/null +++ b/crates/buzz-core/src/invite.rs @@ -0,0 +1,109 @@ +//! Shared pure contracts for relay invite links. +//! +//! The relay transport and database persistence layers both depend on +//! `buzz-core`. Protocol constants and deterministic v2 code operations live +//! here so neither layer becomes the accidental source of truth. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use sha2::{Digest, Sha256}; + +/// Minimum invite lifetime accepted by the mint API: 60 seconds. +pub const MIN_INVITE_TTL_SECS: u64 = 60; + +/// Default invite lifetime when the mint request omits `ttl_secs`: 72 hours. +pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60; + +/// Maximum invite lifetime accepted by the mint API: 30 days. +pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// Maximum supported `max_uses` value. Matches the database constraint. +pub const MAX_INVITE_USES: i32 = 10_000; + +/// Prefix that distinguishes v2 opaque database-backed codes from v1 tokens. +pub const V2_PREFIX: &str = "v2."; + +/// Number of random bytes encoded in a v2 invite code. +pub const V2_SECRET_LEN: usize = 32; + +/// A malformed v2 opaque invite code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidV2InviteCode; + +/// Build a canonical v2 code from its random secret. +pub fn encode_v2_code(secret: &[u8; V2_SECRET_LEN]) -> String { + format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret)) +} + +/// Validate the canonical v2 opaque-code shape without consulting storage. +/// +/// A valid code is exactly `v2.` followed by the unpadded base64url encoding +/// of a 32-byte secret. The decode/re-encode comparison rejects aliases such +/// as padded or otherwise non-canonical encodings. +pub fn validate_v2_code(code: &str) -> Result<(), InvalidV2InviteCode> { + let encoded = code.strip_prefix(V2_PREFIX).ok_or(InvalidV2InviteCode)?; + let secret = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| InvalidV2InviteCode)?; + if secret.len() != V2_SECRET_LEN || URL_SAFE_NO_PAD.encode(&secret) != encoded { + return Err(InvalidV2InviteCode); + } + Ok(()) +} + +/// Hash the complete v2 code to the digest persisted by the database. +pub fn hash_v2_code(code: &str) -> [u8; 32] { + Sha256::digest(code.as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v2_code_round_trip_is_canonical() { + let secret = [7_u8; V2_SECRET_LEN]; + let code = encode_v2_code(&secret); + + assert_eq!(validate_v2_code(&code), Ok(())); + assert_eq!( + code, + format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret)) + ); + } + + #[test] + fn v2_validation_rejects_malformed_and_noncanonical_codes() { + let valid = encode_v2_code(&[7_u8; V2_SECRET_LEN]); + let short = format!( + "{V2_PREFIX}{}", + URL_SAFE_NO_PAD.encode([7_u8; V2_SECRET_LEN - 1]) + ); + + for malformed in [ + "v2.", + "v2.not-base64!", + short.as_str(), + &format!("{valid}="), + ] { + assert_eq!( + validate_v2_code(malformed), + Err(InvalidV2InviteCode), + "accepted malformed v2 code: {malformed}" + ); + } + } + + #[test] + fn v2_hash_covers_the_complete_code() { + let code = encode_v2_code(&[7_u8; V2_SECRET_LEN]); + + let expected: [u8; 32] = Sha256::digest(code.as_bytes()).into(); + + assert_eq!(hash_v2_code(&code), expected); + assert_ne!( + hash_v2_code(&code), + hash_v2_code(code.trim_start_matches(V2_PREFIX)) + ); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index dd57b469378..66b7708f1d1 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod event; pub mod filter; /// Git permission types — ref patterns, protection rules, policy evaluation. pub mod git_perms; +/// Shared invite-link contract constants. +pub mod invite; /// Buzz kind number registry — custom event type constants. pub mod kind; /// Network utilities — SSRF-safe IP classification. diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 23ea3c9d8fe..01f1e172b6e 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -20,6 +20,7 @@ sha2 = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } nostr = { workspace = true } +rand = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/buzz-db/src/admin_moderation.rs b/crates/buzz-db/src/admin_moderation.rs index 2a0d2768947..31efaca3623 100644 --- a/crates/buzz-db/src/admin_moderation.rs +++ b/crates/buzz-db/src/admin_moderation.rs @@ -54,6 +54,31 @@ pub struct AdminReport { pub created_at: DateTime, } +/// Reported message details available only on the admin report detail read. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportedMessage { + /// Message author public key. + pub author_pubkey: String, + /// Complete message content. + pub content: String, + /// Timestamp signed into the message event. + pub created_at: DateTime, + /// Soft-deletion time, when the message has since been deleted. + pub deleted_at: Option>, +} + +/// Deployment-global moderation report detail. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportDetail { + /// Report metadata. + #[serde(flatten)] + pub report: AdminReport, + /// Reported message when the report targets a stored event. + pub message: Option, +} + /// Deployment-global product feedback with source-community provenance. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -128,24 +153,54 @@ pub async fn list_reports( rows.into_iter().map(row_to_report).collect() } -/// Fetch one report globally by its row id. -pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> { +/// Fetch one report globally by its row id, including its event target content. +pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> { let row = sqlx::query( r#" SELECT r.id, r.community_id, c.host AS community_host, r.report_event_id, r.reporter_pubkey, r.target_kind, r.target_event_id, r.target_pubkey, r.target_blob_sha256, r.channel_id, r.report_type, r.note, r.status, r.resolved_by, - r.resolved_at, r.action_id, r.created_at + r.resolved_at, r.action_id, r.created_at, + target.pubkey AS message_author_pubkey, + target.content AS message_content, + target.created_at AS message_created_at, + target.deleted_at AS message_deleted_at FROM moderation_reports r JOIN communities c ON c.id = r.community_id + LEFT JOIN LATERAL ( + SELECT e.pubkey, e.content, e.created_at, e.deleted_at + FROM events e + WHERE r.target_kind = 'event' + AND e.community_id = r.community_id + AND e.id = r.target_event_id + ORDER BY e.created_at DESC + LIMIT 1 + ) target ON TRUE WHERE r.id = $1 "#, ) .bind(report_id) .fetch_optional(pool) .await?; - row.map(row_to_report).transpose() + row.map(|row| { + let message = row + .try_get::>, _>("message_author_pubkey")? + .map(|author_pubkey| -> Result { + Ok(AdminReportedMessage { + author_pubkey: hex::encode(author_pubkey), + content: row.try_get("message_content")?, + created_at: row.try_get("message_created_at")?, + deleted_at: row.try_get("message_deleted_at")?, + }) + }) + .transpose()?; + Ok(AdminReportDetail { + report: row_to_report(row)?, + message, + }) + }) + .transpose() } fn row_to_report(row: sqlx::postgres::PgRow) -> Result { @@ -228,3 +283,206 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result { received_at: row.try_get("received_at")?, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn insert_community(pool: &PgPool, label: &str) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("admin-report-{label}-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_event( + pool: &PgPool, + community_id: Uuid, + event_id: &[u8], + author: &[u8], + content: &str, + deleted_at: Option>, + ) { + sqlx::query( + r#" + INSERT INTO events ( + community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at + ) VALUES ($1, $2, $3, $4, 9, '[]'::jsonb, $5, $6, $7) + "#, + ) + .bind(community_id) + .bind(event_id) + .bind(author) + .bind(Utc::now()) + .bind(content) + .bind(vec![3_u8; 64]) + .bind(deleted_at) + .execute(pool) + .await + .expect("insert event"); + } + + async fn insert_event_report( + pool: &PgPool, + community_id: Uuid, + target_event_id: &[u8], + ) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, id, report_event_id, reporter_pubkey, + target_kind, target_event_id, report_type + ) VALUES ($1, $2, $3, $4, 'event', $5, 'spam') + "#, + ) + .bind(community_id) + .bind(id) + .bind(Uuid::new_v4().as_bytes().repeat(2)) + .bind(vec![4_u8; 32]) + .bind(target_event_id) + .execute(pool) + .await + .expect("insert report"); + id + } + + async fn insert_pubkey_report(pool: &PgPool, community_id: Uuid) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, id, report_event_id, reporter_pubkey, + target_kind, target_pubkey, report_type + ) VALUES ($1, $2, $3, $4, 'pubkey', $5, 'spam') + "#, + ) + .bind(community_id) + .bind(id) + .bind(Uuid::new_v4().as_bytes().repeat(2)) + .bind(vec![4_u8; 32]) + .bind(vec![7_u8; 32]) + .execute(pool) + .await + .expect("insert report"); + id + } + + async fn delete_report_fixture(pool: &PgPool, community_id: Uuid) { + sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1") + .bind(community_id) + .execute(pool) + .await + .expect("delete report fixture"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(pool) + .await + .expect("delete community fixture"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_reads_only_the_same_community_target_and_includes_deleted_content() { + let pool = setup_pool().await; + let report_community = insert_community(&pool, "reported").await; + let other_community = insert_community(&pool, "other").await; + let event_id = vec![1_u8; 32]; + let deleted_at = Utc::now(); + insert_event( + &pool, + report_community, + &event_id, + &[5_u8; 32], + "reported message", + Some(deleted_at), + ) + .await; + insert_event( + &pool, + other_community, + &event_id, + &[6_u8; 32], + "wrong tenant message", + None, + ) + .await; + let report_id = insert_event_report(&pool, report_community, &event_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + let message = detail.message.expect("reported message exists"); + assert_eq!(message.content, "reported message"); + assert_eq!(message.author_pubkey, hex::encode([5_u8; 32])); + assert!(message.deleted_at.is_some()); + + sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1") + .bind(report_community) + .execute(&pool) + .await + .expect("delete report fixture"); + sqlx::query("DELETE FROM events WHERE community_id = ANY($1)") + .bind(vec![report_community, other_community]) + .execute(&pool) + .await + .expect("delete event fixtures"); + sqlx::query("DELETE FROM communities WHERE id = ANY($1)") + .bind(vec![report_community, other_community]) + .execute(&pool) + .await + .expect("delete community fixtures"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_has_no_message_for_non_event_target() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "pubkey-target").await; + let report_id = insert_pubkey_report(&pool, community_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + assert_eq!(detail.report.target_kind, "pubkey"); + assert!(detail.message.is_none()); + + delete_report_fixture(&pool, community_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_has_no_message_when_event_row_is_missing() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "missing-event").await; + let missing_event_id = vec![8_u8; 32]; + let report_id = insert_event_report(&pool, community_id, &missing_event_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + assert_eq!(detail.report.target_kind, "event"); + assert_eq!(detail.report.target, hex::encode(missing_event_id)); + assert!(detail.message.is_none()); + + delete_report_fixture(&pool, community_id).await; + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9c63b2e8ab2..2a3ba9a63e2 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -39,6 +39,8 @@ pub mod product_feedback; pub mod push; /// Reaction persistence. pub mod reaction; +/// Use-limited relay invite persistence (v2 opaque tokens). +pub mod relay_invite; /// Relay-level membership persistence (NIP-43). pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. @@ -563,7 +565,7 @@ impl Db { pub async fn admin_get_report( &self, id: Uuid, - ) -> Result> { + ) -> Result> { admin_moderation::get_report(&self.pool, id).await } @@ -2639,6 +2641,23 @@ impl Db { workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await } + /// Disable all of an owner's workflows in a channel (SEC-006, on + /// membership loss). Returns the number of workflows disabled. + pub async fn disable_workflows_for_owner_in_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + ) -> Result { + workflow::disable_workflows_for_owner_in_channel( + &self.pool, + community_id, + channel_id, + owner_pubkey, + ) + .await + } + /// Delete a workflow and all its runs/approvals. pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { workflow::delete_workflow(&self.pool, community_id, id).await @@ -3028,6 +3047,51 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool, community).await } + /// Mints a v2 use-limited relay invite. The plaintext code is returned + /// exactly once; only its SHA-256 hash is persisted. + /// + /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. + /// `ttl_secs` must be in the shared invite lifetime range. + pub async fn mint_relay_invite( + &self, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, + ) -> Result { + relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + } + + /// Delete one bounded batch of invites expired before `cutoff`. + pub async fn reap_expired_relay_invites( + &self, + cutoff: chrono::DateTime, + ) -> Result { + relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await + } + + /// Atomically claims a v2 relay invite. The full redemption (membership + /// insert, policy evidence, use_count increment) runs in one PostgreSQL + /// transaction with `FOR UPDATE` on the invite row. + /// + /// `token_hash` is the SHA-256 of the presented v2 code (32 bytes). + pub async fn claim_relay_invite( + &self, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + ) -> Result { + relay_invite::claim_relay_invite( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + ) + .await + } + /// Sidecar an accepted product-feedback event, idempotent by event id. pub async fn insert_product_feedback( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1674b0ec4d4..1d1b7e05d42 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -560,7 +560,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 24); + assert_eq!(migrations.len(), 25); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,6 +879,31 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); + + // Use-limited invite links: durable relay_invites table stores only + // the SHA-256 of an opaque v2 code, scoped by community_id. Never + // listed in _operator_global_tables — it is community-scoped. + assert_eq!(migrations[24].version, 25); + let relay_invites = migrations[24].sql.as_str(); + assert!(relay_invites.contains("CREATE TABLE relay_invites")); + assert!(relay_invites + .contains("token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32)")); + assert!(relay_invites.contains("PRIMARY KEY (community_id, id)")); + assert!(relay_invites.contains("UNIQUE (community_id, token_hash)")); + assert!( + relay_invites.contains("max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000)") + ); + assert!(relay_invites.contains("CHECK (max_uses IS NULL OR use_count <= max_uses)")); + assert!(relay_invites.contains("role = 'member'")); + assert!(relay_invites + .contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)")); + assert!(!relay_invites.contains("_operator_global_tables")); + + let desired_schema = include_str!("../../../schema/schema.sql"); + assert!( + desired_schema.contains("CREATE TABLE join_policy_acceptances"), + "desired-state schema must include join-policy evidence used by invite claims", + ); } #[test] @@ -1121,7 +1146,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(24)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(25)); } #[tokio::test] diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs new file mode 100644 index 00000000000..82b71b07bb0 --- /dev/null +++ b/crates/buzz-db/src/relay_invite.rs @@ -0,0 +1,671 @@ +//! Use-limited relay invite persistence (v2 opaque tokens). +//! +//! Unlike the stateless v1 HMAC invite tokens in `buzz-relay::invite_token`, +//! v2 invites are backed by durable rows in `relay_invites`. The table stores +//! only `SHA-256(code)` — never the reusable bearer secret — so a leaked +//! database does not immediately yield valid invite codes. +//! +//! Every lookup binds both `(community_id, token_hash)` to prevent cross-tenant +//! authorization seams: a code minted on tenant A presented to tenant B returns +//! `Invalid`, not a membership. +//! +//! ## Atomic redemption +//! +//! `claim_relay_invite` executes the full redemption in one PostgreSQL +//! transaction: `SELECT FOR UPDATE` on the invite row, membership insert, +//! join-policy evidence insert, and `use_count` increment all commit together. +//! `FOR UPDATE` serializes concurrent claims for one invite across relay +//! processes — exactly one claimant can win the final slot. + +use buzz_core::invite::{ + encode_v2_code, hash_v2_code, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, + V2_SECRET_LEN, +}; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row as _}; + +use crate::error::Result; +use crate::CommunityId; + +/// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are +/// typed variants so the relay layer can map them to distinct HTTP responses +/// without inspecting database errors. +#[derive(Debug, PartialEq)] +pub enum ClaimOutcome { + /// A new relay member was inserted. `use_count` is the post-increment count; + /// `uses_remaining` is `None` for unlimited invites. + Joined { + /// Post-claim use count. + use_count: i32, + /// Remaining slots, or `None` when the invite is unlimited. + uses_remaining: Option, + }, + /// The claimer was already a member. `use_count` was NOT incremented. + AlreadyMember { + /// Current use count (unchanged by this claim). + use_count: i32, + /// Remaining slots, or `None` when the invite is unlimited. + uses_remaining: Option, + }, + /// The invite's `expires_at` has passed. + Expired, + /// The invite's use budget is fully consumed. + Exhausted, + /// No invite row matches `(community_id, token_hash)`. + Invalid, +} + +/// A freshly minted v2 invite, including the plaintext code and metadata. +#[derive(Debug)] +pub struct MintedInvite { + /// The full v2 code string (`v2.`). Returned to the caller + /// exactly once; the database stores only the SHA-256 hash. + pub code: String, + /// When the invite expires (UTC). + pub expires_at: DateTime, + /// `None` means unlimited; `Some(n)` means at most `n` uses. + pub max_uses: Option, + /// Remaining uses at mint time (equals `max_uses` when bounded, `None` + /// when unlimited). + pub uses_remaining: Option, + /// The invite's database-generated UUID. + pub invite_id: uuid::Uuid, +} + +fn validate_mint_inputs(ttl_secs: u64, max_uses: Option) -> Result<()> { + if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl_secs) { + return Err(crate::error::DbError::InvalidData(format!( + "ttl_secs must be between {MIN_INVITE_TTL_SECS} and {MAX_INVITE_TTL_SECS}" + ))); + } + + if let Some(max_uses) = max_uses { + if !(1..=MAX_INVITE_USES).contains(&max_uses) { + return Err(crate::error::DbError::InvalidData(format!( + "max_uses must be between 1 and {MAX_INVITE_USES}" + ))); + } + } + + Ok(()) +} + +/// Mint a v2 invite: generate a 32-byte random secret, hash it, persist the +/// row, and return the plaintext code plus metadata. +/// +/// `ttl_secs` must be in the shared invite lifetime range. +/// `max_uses` must be `None` (unlimited) or `Some(1..=10000)`. +pub async fn mint_relay_invite( + pool: &PgPool, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, +) -> Result { + validate_mint_inputs(ttl_secs, max_uses)?; + + // Generate 32 random bytes and encode as base64url — this is the secret. + let secret: [u8; V2_SECRET_LEN] = rand::random(); + let code = encode_v2_code(&secret); + let token_hash = hash_v2_code(&code); + let now = Utc::now(); + let expires_at = now + chrono::Duration::seconds(ttl_secs as i64); + + let row = sqlx::query( + "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \ + VALUES ($1, $2, $3, $4, $5) \ + RETURNING id", + ) + .bind(community.as_uuid()) + .bind(token_hash.as_slice()) + .bind(max_uses) + .bind(expires_at) + .bind(created_by) + .fetch_one(pool) + .await?; + + let invite_id: uuid::Uuid = row.try_get("id")?; + + Ok(MintedInvite { + code, + expires_at, + max_uses, + uses_remaining: max_uses, + invite_id, + }) +} + +fn log_claim_outcome( + community: CommunityId, + invite_id: Option, + outcome: &'static str, + max_uses: Option, + use_count: Option, +) { + tracing::info!( + community = %community, + invite_id = ?invite_id, + outcome, + max_uses = ?max_uses, + use_count = ?use_count, + "relay invite claim completed" + ); +} + +/// Maximum rows deleted by one retention sweep so cleanup cannot monopolize +/// the invite table on a busy deployment. +const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000; + +/// Delete one bounded batch of invite rows expired before `cutoff`. +/// +/// The relay calls this from its leader-only periodic tick. Ordering by the +/// expiry index makes old rows drain first without turning cleanup into an +/// unbounded transaction. +pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> Result { + let result = sqlx::query( + "DELETE FROM relay_invites \ + WHERE (community_id, id) IN (\ + SELECT community_id, id FROM relay_invites \ + WHERE expires_at < $1 \ + ORDER BY expires_at \ + LIMIT $2\ + )", + ) + .bind(cutoff) + .bind(RETENTION_SWEEP_BATCH_SIZE) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// Atomically claim a v2 relay invite. +/// +/// Executes the full redemption in one PostgreSQL transaction: +/// 1. Hash the presented code. +/// 2. `SELECT ... FOR UPDATE` on the invite row scoped by `(community, token_hash)`. +/// 3. If no row → `Invalid`. +/// 4. If `expires_at <= now()` → `Expired`. +/// 5. Check existing membership. +/// 6. If already a member → insert policy evidence (if configured), commit, +/// return `AlreadyMember` (no increment). +/// 7. If `max_uses` is set and `use_count >= max_uses` → `Exhausted`. +/// 8. Insert relay member with role `member`, `added_by = 'invite'`. +/// 9. Insert join-policy acceptance evidence (if configured). +/// 10. Increment `use_count`. +/// 11. Commit. +/// +/// `FOR UPDATE` serializes concurrent claims so exactly one claimant wins the +/// final slot. Membership insertion, policy evidence, and consumption share +/// one commit — a failure in any rolls back all. +pub async fn claim_relay_invite( + pool: &PgPool, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, +) -> Result { + let mut tx = pool.begin().await?; + + // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. + let row = sqlx::query( + "SELECT id, max_uses, use_count, expires_at \ + FROM relay_invites \ + WHERE community_id = $1 AND token_hash = $2 \ + FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(token_hash) + .fetch_optional(&mut *tx) + .await?; + + // 3. No matching invite. + let Some(invite) = row else { + tx.rollback().await?; + log_claim_outcome(community, None, "invalid", None, None); + return Ok(ClaimOutcome::Invalid); + }; + + let invite_id: uuid::Uuid = invite.try_get("id")?; + let max_uses: Option = invite.try_get("max_uses")?; + let use_count: i32 = invite.try_get("use_count")?; + let expires_at: DateTime = invite.try_get("expires_at")?; + + // Expiry is checked before membership deliberately. An expired bearer must + // not authorize fresh policy-acceptance evidence, even for an existing + // member; exhausted-but-live invites remain valid for idempotent retries. + if expires_at <= Utc::now() { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "expired", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::Expired); + } + + let uses_remaining = || max_uses.map(|mu| mu - use_count); + + // 5. Check existing membership. + let existing = + sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_some() { + // 6. Already a member — insert policy evidence but do NOT increment. + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .bind(version) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "already_member", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }); + } + + // 7. Capacity check. + if let Some(mu) = max_uses { + if use_count >= mu { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "exhausted", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::Exhausted); + } + } + + // 8. Insert relay member. The conflict branch covers a claimant admitted + // concurrently through a different invite: only the transaction that + // actually inserted membership may consume this invite. + let inserted = sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'member', 'invite') \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .execute(&mut *tx) + .await? + .rows_affected() + > 0; + + // 9. Insert join-policy acceptance evidence. This is required for both a + // new member and a claimant whose concurrent membership insert won first. + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .bind(version) + .execute(&mut *tx) + .await?; + } + + if !inserted { + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "already_member", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }); + } + + // 10. Increment use_count (for every new member, even unlimited). + let new_use_count = use_count + 1; + sqlx::query("UPDATE relay_invites SET use_count = $1 WHERE community_id = $2 AND id = $3") + .bind(new_use_count) + .bind(community.as_uuid()) + .bind(invite_id) + .execute(&mut *tx) + .await?; + + // 11. Commit. + tx.commit().await?; + + let new_uses_remaining = max_uses.map(|mu| mu - new_use_count); + + log_claim_outcome( + community, + Some(invite_id), + "joined", + max_uses, + Some(new_use_count), + ); + + Ok(ClaimOutcome::Joined { + use_count: new_use_count, + uses_remaining: new_uses_remaining, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay_members::is_relay_member; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("relay-invite-test-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + async fn delete_test_community(pool: &PgPool, community: CommunityId) { + let mut tx = pool.begin().await.expect("begin test cleanup"); + sqlx::query("DELETE FROM relay_invites WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test invites"); + sqlx::query("DELETE FROM relay_members WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test members"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test community"); + tx.commit().await.expect("commit test cleanup"); + } + + fn test_pubkey() -> String { + format!("{:064x}", Uuid::new_v4().as_u128()) + } + + async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 { + sqlx::query_scalar( + "SELECT use_count FROM relay_invites WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(invite_id) + .fetch_one(pool) + .await + .expect("read invite use_count") + } + + #[test] + fn mint_validation_rejects_invalid_bounds_before_database_access() { + for (ttl, max_uses) in [ + (MIN_INVITE_TTL_SECS - 1, None), + (MAX_INVITE_TTL_SECS + 1, None), + (3600, Some(0)), + (3600, Some(-1)), + (3600, Some(MAX_INVITE_USES + 1)), + ] { + let error = validate_mint_inputs(ttl, max_uses).expect_err("invalid mint contract"); + assert!(matches!(error, crate::DbError::InvalidData(_)), "{error:?}"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bounded_claim_exhausts_and_existing_member_retry_does_not_consume() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let first = test_pubkey(); + let second = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite(&pool, community, &hash, &first, None) + .await + .expect("first claim"), + ClaimOutcome::Joined { + use_count: 1, + uses_remaining: Some(0), + } + ); + assert_eq!( + claim_relay_invite(&pool, community, &hash, &first, None) + .await + .expect("idempotent retry"), + ClaimOutcome::AlreadyMember { + use_count: 1, + uses_remaining: Some(0), + } + ); + assert_eq!( + claim_relay_invite(&pool, community, &hash, &second, None) + .await + .expect("exhausted claim"), + ClaimOutcome::Exhausted + ); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 1); + assert!(is_relay_member(&pool, community, &first) + .await + .expect("first membership")); + assert!(!is_relay_member(&pool, community, &second) + .await + .expect("second membership")); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_claims_serialize_the_final_slot() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let first = test_pubkey(); + let second = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + let (first_outcome, second_outcome) = tokio::join!( + claim_relay_invite(&pool, community, &hash, &first, None), + claim_relay_invite(&pool, community, &hash, &second, None), + ); + let outcomes = [ + first_outcome.expect("first concurrent claim"), + second_outcome.expect("second concurrent claim"), + ]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimOutcome::Joined { .. })) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimOutcome::Exhausted)) + .count(), + 1 + ); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 1); + let admitted = is_relay_member(&pool, community, &first) + .await + .expect("first membership") as u8 + + is_relay_member(&pool, community, &second) + .await + .expect("second membership") as u8; + assert_eq!(admitted, 1); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expiry_and_tenant_scope_return_typed_failures() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2)) + .await + .expect("mint invite"); + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite(&pool, community_b, &hash, &test_pubkey(), None) + .await + .expect("cross-tenant claim"), + ClaimOutcome::Invalid + ); + + sqlx::query( + "UPDATE relay_invites SET expires_at = now() - interval '1 second' \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_a.as_uuid()) + .bind(invite.invite_id) + .execute(&pool) + .await + .expect("expire invite"); + assert_eq!( + claim_relay_invite(&pool, community_a, &hash, &test_pubkey(), None) + .await + .expect("expired claim"), + ClaimOutcome::Expired + ); + assert_eq!(use_count(&pool, community_a, invite.invite_id).await, 0); + delete_test_community(&pool, community_a).await; + delete_test_community(&pool, community_b).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn retention_sweep_deletes_only_invites_older_than_cutoff() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint old invite"); + let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint recent invite"); + let cutoff = Utc::now() - chrono::Duration::days(30); + + sqlx::query("UPDATE relay_invites SET expires_at = $1 WHERE community_id = $2 AND id = $3") + .bind(cutoff - chrono::Duration::seconds(1)) + .bind(community.as_uuid()) + .bind(old.invite_id) + .execute(&pool) + .await + .expect("age old invite"); + + assert_eq!( + reap_expired_relay_invites(&pool, cutoff) + .await + .expect("reap expired invites"), + 1 + ); + let remaining: Vec = + sqlx::query_scalar("SELECT id FROM relay_invites WHERE community_id = $1 ORDER BY id") + .bind(community.as_uuid()) + .fetch_all(&pool) + .await + .expect("read remaining invites"); + assert_eq!(remaining, vec![recent.invite_id]); + + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unlimited_invites_count_each_new_member() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let invite = mint_relay_invite(&pool, community, "owner", 3600, None) + .await + .expect("mint unlimited invite"); + let hash = hash_v2_code(&invite.code); + + for (expected_count, pubkey) in [(1, test_pubkey()), (2, test_pubkey())] { + assert_eq!( + claim_relay_invite(&pool, community, &hash, &pubkey, None) + .await + .expect("unlimited claim"), + ClaimOutcome::Joined { + use_count: expected_count, + uses_remaining: None, + } + ); + } + assert_eq!(use_count(&pool, community, invite.invite_id).await, 2); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_evidence_failure_rolls_back_membership_and_consumption() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let pubkey = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + let error = claim_relay_invite(&pool, community, &hash, &pubkey, Some("too-short")) + .await + .expect_err("policy CHECK must reject an invalid version"); + assert!(matches!(error, crate::DbError::Sqlx(_)), "{error:?}"); + assert!(!is_relay_member(&pool, community, &pubkey) + .await + .expect("membership after rollback")); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 0); + + assert!(matches!( + claim_relay_invite(&pool, community, &hash, &pubkey, None) + .await + .expect("claim after rollback"), + ClaimOutcome::Joined { use_count: 1, .. } + )); + delete_test_community(&pool, community).await; + } +} diff --git a/crates/buzz-db/src/workflow.rs b/crates/buzz-db/src/workflow.rs index 9c02f162c90..7a2396c1fd7 100644 --- a/crates/buzz-db/src/workflow.rs +++ b/crates/buzz-db/src/workflow.rs @@ -707,6 +707,36 @@ pub async fn set_workflow_enabled( Ok(()) } +/// Disable all of `owner_pubkey`'s workflows in a channel (SEC-006). +/// +/// Called when the owner loses channel membership (kind 9001 removal or kind +/// 9022 leave) so their workflows stop firing durably — across pods and +/// restarts — rather than only until the per-fire authority gate happens to +/// run. Idempotent; returns the number of workflows disabled so the caller +/// can decide whether a trigger-cache invalidation is needed. +pub async fn disable_workflows_for_owner_in_channel( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], +) -> Result { + let affected = sqlx::query( + r#" + UPDATE workflows + SET enabled = FALSE + WHERE community_id = $1 AND channel_id = $2 AND owner_pubkey = $3 AND enabled = TRUE + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(owner_pubkey) + .execute(pool) + .await? + .rows_affected(); + + Ok(affected) +} + /// Delete a workflow and all its runs/approvals (CASCADE). /// /// NOTE: see the cache-invalidation note on [`update_workflow`]. The relay's @@ -2272,4 +2302,100 @@ mod tests { "B's approval must remain pending after A is granted" ); } + + // -- SEC-006: disable-on-membership-loss primitive ------------------------- + + /// `disable_workflows_for_owner_in_channel` must disable exactly the + /// departing owner's enabled workflows in that channel — not other owners' + /// workflows, not the same owner's workflows in other channels — and be + /// idempotent. Disabled workflows must drop out of the trigger-path list. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn disable_for_owner_scopes_to_owner_and_channel() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + + let departing = vec![0xd1; 32]; + let staying = vec![0xd2; 32]; + ensure_user(&pool, community, &departing) + .await + .expect("ensure departing"); + ensure_user(&pool, community, &staying) + .await + .expect("ensure staying"); + + let channel_a = make_channel(&pool, community, &departing).await; + let channel_b = make_channel(&pool, community, &departing).await; + + let def = r#"{"trigger":{"on":"message_posted"},"steps":[]}"#; + let wf_departing_a = create_workflow( + &pool, + community, + Some(channel_a), + &departing, + "departing-a", + def, + &[0u8; 32], + ) + .await + .expect("wf departing a"); + let wf_departing_b = create_workflow( + &pool, + community, + Some(channel_b), + &departing, + "departing-b", + def, + &[0u8; 32], + ) + .await + .expect("wf departing b"); + let wf_staying_a = create_workflow( + &pool, + community, + Some(channel_a), + &staying, + "staying-a", + def, + &[0u8; 32], + ) + .await + .expect("wf staying a"); + + let disabled = + disable_workflows_for_owner_in_channel(&pool, community, channel_a, &departing) + .await + .expect("disable"); + assert_eq!( + disabled, 1, + "exactly the departing owner's channel-A workflow" + ); + + // Idempotent: second call finds nothing enabled. + let again = disable_workflows_for_owner_in_channel(&pool, community, channel_a, &departing) + .await + .expect("disable again"); + assert_eq!(again, 0, "second disable must be a no-op"); + + let enabled_a = list_enabled_channel_workflows(&pool, community, channel_a) + .await + .expect("list channel a"); + let enabled_a_ids: Vec = enabled_a.iter().map(|w| w.id).collect(); + assert!( + !enabled_a_ids.contains(&wf_departing_a), + "departing owner's workflow must no longer be trigger-eligible" + ); + assert!( + enabled_a_ids.contains(&wf_staying_a), + "other owners' workflows in the channel must be untouched" + ); + + let enabled_b = list_enabled_channel_workflows(&pool, community, channel_b) + .await + .expect("list channel b"); + assert!( + enabled_b.iter().any(|w| w.id == wf_departing_b), + "same owner's workflow in a different channel must be untouched" + ); + } } diff --git a/crates/buzz-dev-mcp/src/shim.rs b/crates/buzz-dev-mcp/src/shim.rs index 36a07a18ca4..cccf0e6eca1 100644 --- a/crates/buzz-dev-mcp/src/shim.rs +++ b/crates/buzz-dev-mcp/src/shim.rs @@ -171,15 +171,126 @@ fn derive_git_email(pubkey_hex: &str) -> String { format!("{pubkey_hex}@{host}") } +/// Stable identity contract for git attribution: the bare agent display name, +/// never channel-qualified, safe to embed in commit history. +/// +/// Deliberately distinct from `BUZZ_ACP_SESSION_TITLE`, which is per-session UI +/// chrome and may be composed (`Agent · #channel`) by consumers. Commits +/// outlive sessions, so git attribution must not follow a mutable title. +/// +/// Nothing writes this yet — when unset, [`build_git_env`] falls back to the +/// npub, which is byte-for-byte today's behavior. +const DISPLAY_NAME_ENV_VAR: &str = "BUZZ_ACP_DISPLAY_NAME"; + +/// Max characters in a git author name. Nostr display names are unbounded. +const MAX_GIT_USER_NAME_CHARS: usize = 80; + +/// Characters git's `ident.c` treats as "crud": stripped from both ends of a +/// name, and — when a name is *nothing but* these — rejected outright with +/// `fatal: name consists only of disallowed characters`. +/// +/// Verified empirically against git 2.54.0 by committing with each ASCII byte +/// 32..=126 as the entire `user.name`: exactly space, `"`, `'`, `,`, `:`, `;`, +/// `<`, `>`, and `\` abort. Control characters abort too (the predicate is +/// `c <= 32`). Note `.` is *not* crud in this version despite older lore. +fn is_git_crud(c: char) -> bool { + c <= ' ' || matches!(c, '"' | '\'' | ',' | ':' | ';' | '<' | '>' | '\\') +} + +/// Characters in Unicode general category `Cf` (format): zero-width space and +/// joiners, bidi embedding/override marks, invisible math operators, interlinear +/// annotations, and tag characters. +/// +/// `char::is_control` covers only `Cc`, so every one of these survives it — and +/// none is whitespace or [`is_git_crud`]. A display name of nothing but U+200B +/// ZERO WIDTH SPACE would therefore satisfy the "at least one non-crud +/// character" gate and hand git a visually blank author instead of falling back +/// to the npub. An embedded U+202E RIGHT-TO-LEFT OVERRIDE is worse: it makes a +/// commit's persisted author line render as something other than what it says, +/// the same confusion the angle-bracket filter exists to prevent. +/// +/// The whole category is rejected rather than the two known-bad marks, because +/// the boundary that matters is "invisible or reorders text", not "the codepoint +/// someone thought of". Ranges transcribed from the UCD's +/// `DerivedGeneralCategory.txt` (17.0.0) and independently cross-checked against +/// Python's `unicodedata` (16.0.0); both yield exactly these 21 ranges. Inlined +/// rather than taking a Unicode-tables dependency for one predicate. +fn is_unicode_format(c: char) -> bool { + matches!(c, + '\u{00AD}' + | '\u{0600}'..='\u{0605}' + | '\u{061C}' + | '\u{06DD}' + | '\u{070F}' + | '\u{0890}'..='\u{0891}' + | '\u{08E2}' + | '\u{180E}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{2064}' + | '\u{2066}'..='\u{206F}' + | '\u{FEFF}' + | '\u{FFF9}'..='\u{FFFB}' + | '\u{110BD}' + | '\u{110CD}' + | '\u{13430}'..='\u{1343F}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0001}' + | '\u{E0020}'..='\u{E007F}' + ) +} + +/// Normalize a Buzz display name into a git author name, or `None` to fall +/// back to the npub. +/// +/// Strips control and Unicode format characters plus angle brackets, collapses +/// whitespace runs, trims, and caps at [`MAX_GIT_USER_NAME_CHARS`] by `chars()` +/// so a multi-byte name cannot be split mid-UTF-8. Angle brackets go because git +/// silently drops them rather than erroring — `Duncan ` would +/// render as `Duncan evil@x.com `, which forges nothing but reads as +/// though it might. +/// +/// Returns `None` unless at least one non-crud character survives. A bare +/// emptiness check is not sufficient: git rejects a name built only of crud, +/// so a display name of `;;` or `""` would abort **every commit** the agent +/// makes. Falling back to the npub keeps the agent able to commit. +fn sanitize_git_user_name(raw: &str) -> Option { + let collapsed = raw + .split_whitespace() + .map(|word| { + word.chars() + .filter(|c| !c.is_control() && !is_unicode_format(*c) && *c != '<' && *c != '>') + .collect::() + }) + .filter(|word| !word.is_empty()) + .collect::>() + .join(" "); + let name: String = collapsed + .chars() + .take(MAX_GIT_USER_NAME_CHARS) + .collect::() + .trim_end() + .to_string(); + name.chars().any(|c| !is_git_crud(c)).then_some(name) +} + /// Build GIT_CONFIG_COUNT/KEY/VALUE env vars for ephemeral nostr git config. /// Composes with any existing GIT_CONFIG_COUNT in the environment. When launched /// via buzz-agent (which clears env), the base is always 0 — composition only /// matters when dev-mcp is run directly with pre-existing GIT_CONFIG vars. fn build_git_env(info: &KeyInfo) -> Vec<(String, String)> { let email = derive_git_email(&info.pubkey_hex); + // Display name for humans reading `git log`; the pubkey stays in the email, + // which is what NIP-98 auth, NIP-GS signing, and contributor matching key on. + let user_name = std::env::var(DISPLAY_NAME_ENV_VAR) + .ok() + .as_deref() + .and_then(sanitize_git_user_name) + .unwrap_or_else(|| info.npub.clone()); let entries: Vec<(&str, String)> = vec![ - // Identity — npub as display name, NIP-05-style email - ("user.name", info.npub.clone()), + // Identity — Buzz display name (npub fallback), NIP-05-style email + ("user.name", user_name), ("user.email", email), // Nostr credential helper is additive — it silently declines non-Buzz // remotes (exits 0, no credential), so git falls through to system @@ -246,3 +357,339 @@ pub fn artifact_dir(session_root: &Path) -> PathBuf { let _ = std::fs::create_dir_all(&p); p } + +#[cfg(test)] +mod git_user_name_tests { + use super::{ + build_git_env, is_git_crud, is_unicode_format, sanitize_git_user_name, KeyInfo, + MAX_GIT_USER_NAME_CHARS, + }; + use std::sync::Mutex; + + /// Env-var-touching tests must run serially — env vars are process-global. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + const PUBKEY_HEX: &str = "dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95"; + const NPUB: &str = "npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7"; + + fn key_info() -> KeyInfo { + KeyInfo { + keyfile_path: "/tmp/.nostr-key".into(), + pubkey_hex: PUBKEY_HEX.into(), + npub: NPUB.into(), + } + } + + /// Read a git config value back out of the flat GIT_CONFIG_KEY_n/VALUE_n pairs. + fn git_config(env: &[(String, String)], key: &str) -> Option { + let idx = env + .iter() + .find(|(k, v)| k.starts_with("GIT_CONFIG_KEY_") && v == key)? + .0 + .strip_prefix("GIT_CONFIG_KEY_")? + .to_owned(); + env.iter() + .find(|(k, _)| *k == format!("GIT_CONFIG_VALUE_{idx}")) + .map(|(_, v)| v.clone()) + } + + #[test] + fn test_ordinary_name_passes_through_unchanged() { + assert_eq!(sanitize_git_user_name("Duncan"), Some("Duncan".into())); + } + + #[test] + fn test_angle_brackets_are_stripped_so_no_second_email_is_rendered() { + // git drops the brackets itself and renders `Duncan evil@x.com + // ` — no forgery, but a confusing author line. + assert_eq!( + sanitize_git_user_name("Duncan "), + Some("Duncan evil@x.com".into()) + ); + } + + #[test] + fn test_whitespace_control_characters_become_a_single_separator() { + // Newline, tab and carriage return are whitespace: they collapse to one + // space like any other run, so a multi-line name stays readable. + assert_eq!( + sanitize_git_user_name("Dun\ncan\tThe\r\nIdaho"), + Some("Dun can The Idaho".into()) + ); + } + + #[test] + fn test_non_whitespace_control_characters_are_dropped_outright() { + // NUL is the important one: an interior NUL makes `Command::env` fail + // the entire spawn upstream, so it must never survive to git config. + let got = sanitize_git_user_name("Idaho\0Blade\u{7}").expect("non-empty"); + assert_eq!(got, "IdahoBlade"); + assert!(!got.chars().any(char::is_control)); + } + + #[test] + fn test_internal_whitespace_runs_collapse_to_one_space() { + assert_eq!( + sanitize_git_user_name(" Duncan Idaho "), + Some("Duncan Idaho".into()) + ); + } + + #[test] + fn test_whitespace_only_name_falls_back_to_npub() { + assert_eq!(sanitize_git_user_name(" \t\n "), None); + } + + #[test] + fn test_empty_name_falls_back_to_npub() { + assert_eq!(sanitize_git_user_name(""), None); + } + + #[test] + fn test_crud_only_name_falls_back_rather_than_aborting_every_commit() { + // git rejects a name built only of crud with `fatal: name consists + // only of disallowed characters`, which would break EVERY commit the + // agent makes. Verified against git 2.54.0. + for raw in ["<>", ";;", "\"\"", "''", ",", ":", "\\", ",;:"] { + assert_eq!( + sanitize_git_user_name(raw), + None, + "crud-only name {raw:?} must fall back to the npub" + ); + } + } + + #[test] + fn test_crud_mixed_with_real_characters_is_kept() { + // Legitimate names contain crud; only an all-crud result is fatal. + assert_eq!(sanitize_git_user_name("O'Brien"), Some("O'Brien".into())); + assert_eq!( + sanitize_git_user_name("Smith, Jr."), + Some("Smith, Jr.".into()) + ); + } + + #[test] + fn test_over_length_name_is_truncated_to_the_cap() { + let long = "a".repeat(200); + let got = sanitize_git_user_name(&long).expect("non-empty"); + assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS); + } + + #[test] + fn test_truncation_never_splits_a_multibyte_character() { + let long = "🐝".repeat(200); + let got = sanitize_git_user_name(&long).expect("non-empty"); + assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS); + assert!(got.chars().all(|c| c == '🐝'), "no replacement chars"); + } + + #[test] + fn test_truncation_does_not_leave_a_trailing_space() { + // Cutting mid-word would otherwise strand the separator at the end. + let raw = format!("{} tail", "a".repeat(MAX_GIT_USER_NAME_CHARS - 1)); + let got = sanitize_git_user_name(&raw).expect("non-empty"); + assert!(!got.ends_with(' '), "got {got:?}"); + } + + #[test] + fn test_non_ascii_names_survive() { + assert_eq!( + sanitize_git_user_name("Élodie 🐝"), + Some("Élodie 🐝".into()) + ); + } + + #[test] + fn test_format_only_name_falls_back_to_npub() { + // U+200B is neither control, nor whitespace, nor crud, so before Cf + // filtering this passed the non-crud gate and handed git a visually + // blank author instead of falling back. + assert_eq!(sanitize_git_user_name("\u{200B}\u{200B}"), None); + // Same class, different marks: joiner, word joiner, BOM, bidi override. + for raw in ["\u{200D}", "\u{2060}", "\u{FEFF}", "\u{202E}", "\u{00AD}"] { + assert_eq!( + sanitize_git_user_name(raw), + None, + "format-only name {raw:?} must fall back to the npub" + ); + } + } + + #[test] + fn test_bidi_override_is_stripped_and_the_name_is_kept() { + // A trailing RLO would reorder everything after it in `git log`, so the + // mark goes and the readable name stays. + assert_eq!( + sanitize_git_user_name("Duncan\u{202E}"), + Some("Duncan".into()) + ); + assert_eq!( + sanitize_git_user_name("Dun\u{202E}can Idaho"), + Some("Duncan Idaho".into()) + ); + } + + #[test] + fn test_zero_width_space_inside_a_word_is_removed_without_splitting_it() { + // U+200B is not whitespace, so it must not become a separator: the word + // rejoins rather than turning into "Dun can". + assert_eq!( + sanitize_git_user_name("Dun\u{200B}can"), + Some("Duncan".into()) + ); + } + + #[test] + fn test_format_characters_do_not_consume_the_length_budget() { + // Filtering happens before truncation, so invisible padding cannot + // shorten the visible name. + let raw = format!("{}{}", "\u{200B}".repeat(200), "a".repeat(90)); + let got = sanitize_git_user_name(&raw).expect("non-empty"); + assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS); + assert!(got.chars().all(|c| c == 'a'), "got {got:?}"); + } + + #[test] + fn test_unicode_format_covers_every_cf_range_and_nothing_adjacent() { + // Both endpoints of each of the 21 `Cf` ranges in UCD 17.0.0. Endpoints + // are what a transcription error moves, so they are what gets asserted. + for c in [ + '\u{00AD}', + '\u{0600}', + '\u{0605}', + '\u{061C}', + '\u{06DD}', + '\u{070F}', + '\u{0890}', + '\u{0891}', + '\u{08E2}', + '\u{180E}', + '\u{200B}', + '\u{200F}', + '\u{202A}', + '\u{202E}', + '\u{2060}', + '\u{2064}', + '\u{2066}', + '\u{206F}', + '\u{FEFF}', + '\u{FFF9}', + '\u{FFFB}', + '\u{110BD}', + '\u{110CD}', + '\u{13430}', + '\u{1343F}', + '\u{1BCA0}', + '\u{1BCA3}', + '\u{1D173}', + '\u{1D17A}', + '\u{E0001}', + '\u{E0020}', + '\u{E007F}', + ] { + assert!(is_unicode_format(c), "U+{:04X} is Cf", c as u32); + } + // Codepoints immediately outside those ranges, plus ordinary characters. + // U+2065 is the notable one: it sits *inside* the 2060..206F block but + // is unassigned, not `Cf`. + for c in [ + '\u{00AC}', + '\u{00AE}', + '\u{05FF}', + '\u{0606}', + '\u{061B}', + '\u{061D}', + '\u{200A}', + '\u{2010}', + '\u{2029}', + '\u{202F}', + '\u{2065}', + '\u{205F}', + '\u{2070}', + '\u{FEFE}', + '\u{FFF8}', + '\u{FFFC}', + '\u{110BC}', + '\u{1342F}', + '\u{E0000}', + '\u{E0080}', + 'a', + ' ', + '🐝', + 'É', + ] { + assert!(!is_unicode_format(c), "U+{:04X} is not Cf", c as u32); + } + } + + #[test] + fn test_build_git_env_uses_display_name_and_leaves_email_on_the_pubkey() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("BUZZ_ACP_DISPLAY_NAME", "Duncan"); + std::env::remove_var("BUZZ_RELAY_URL"); + std::env::remove_var("GIT_CONFIG_COUNT"); + let env = build_git_env(&key_info()); + std::env::remove_var("BUZZ_ACP_DISPLAY_NAME"); + + assert_eq!(git_config(&env, "user.name").as_deref(), Some("Duncan")); + // The pubkey — the thing NIP-98 auth, NIP-GS signing, and contributor + // matching key on — must stay in the email untouched. + assert_eq!( + git_config(&env, "user.email").as_deref(), + Some(format!("{PUBKEY_HEX}@buzz").as_str()) + ); + assert_eq!( + git_config(&env, "user.signingkey").as_deref(), + Some(PUBKEY_HEX) + ); + } + + #[test] + fn test_build_git_env_falls_back_to_npub_when_display_name_unset() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("BUZZ_ACP_DISPLAY_NAME"); + std::env::remove_var("BUZZ_RELAY_URL"); + std::env::remove_var("GIT_CONFIG_COUNT"); + let env = build_git_env(&key_info()); + + // Today's behavior, and what every agent gets until a writer for + // BUZZ_ACP_DISPLAY_NAME lands on the Desktop side. + assert_eq!(git_config(&env, "user.name").as_deref(), Some(NPUB)); + assert_eq!( + git_config(&env, "user.email").as_deref(), + Some(format!("{PUBKEY_HEX}@buzz").as_str()) + ); + } + + #[test] + fn test_build_git_env_falls_back_to_npub_when_display_name_is_unusable() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("BUZZ_RELAY_URL"); + std::env::remove_var("GIT_CONFIG_COUNT"); + + // Crud-only and format-only names both reach git as the npub — one + // would abort every commit, the other would render as blank. + for raw in ["<>", "\u{200B}"] { + std::env::set_var("BUZZ_ACP_DISPLAY_NAME", raw); + let env = build_git_env(&key_info()); + assert_eq!( + git_config(&env, "user.name").as_deref(), + Some(NPUB), + "unusable display name {raw:?} must reach git as the npub" + ); + } + std::env::remove_var("BUZZ_ACP_DISPLAY_NAME"); + } + + #[test] + fn test_git_crud_set_matches_observed_git_behavior() { + // Empirically derived from git 2.54.0: these bytes, alone, abort a commit. + for c in [' ', '"', '\'', ',', ':', ';', '<', '>', '\\', '\t', '\n'] { + assert!(is_git_crud(c), "{c:?} should be crud"); + } + for c in ['.', '-', '_', '@', '(', 'a', '🐝'] { + assert!(!is_git_crud(c), "{c:?} should not be crud"); + } + } +} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 10a83f03242..01f78a2d496 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -92,3 +92,4 @@ reqwest = { workspace = true } tokio-tungstenite = { workspace = true } futures = "0.3" flate2 = "1.1.9" +opentelemetry_sdk = { workspace = true, features = ["testing"] } diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index f0f4c5e633e..21f30065f0a 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -126,7 +126,7 @@ async fn report_detail( State(state): State>, headers: HeaderMap, Path(id): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { authorize(&state, &headers)?; state .db @@ -374,6 +374,36 @@ mod tests { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + #[tokio::test] + async fn report_detail_requires_admin_host_before_database_access() { + let response = router(test_state().await) + .oneshot( + Request::builder() + .uri(format!("/reports/{}", Uuid::nil())) + .header(header::HOST, "community.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn report_detail_rejects_unknown_report() { + let response = router(test_state().await) + .oneshot( + Request::builder() + .uri(format!("/reports/{}", Uuid::nil())) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); + } + #[tokio::test] async fn feedback_attachment_requires_admin_host_before_database_access() { let response = router(test_state().await) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index c8ec0cdbc94..0a28380e06b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -192,8 +192,14 @@ async fn check_nip98_replay_with_guard( /// pass and the relay would proceed against the wrong tenant's auth context), /// and (b) reject every legitimate request whose community host isn't the /// single configured one. Substituting `tenant.host()` closes both directions. +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). Callers pass the route's own unprefixed path — the same +/// literal the route is declared with — and the prefix is applied here, at the +/// single choke point, so a prefixed deployment reconstructs the URL the client +/// actually signed instead of 401ing on a path mismatch. pub(crate) fn nip98_expected_url( config_relay_url: &str, + base_path: &str, tenant: &TenantContext, path: &str, ) -> String { @@ -202,7 +208,7 @@ pub(crate) fn nip98_expected_url( } else { "http" }; - format!("{scheme}://{}{path}", tenant.host()) + format!("{scheme}://{}{base_path}{path}", tenant.host()) } /// Construct the NIP-42 expected `relay` URL for a connection bound to `tenant`. @@ -220,15 +226,22 @@ pub(crate) fn nip98_expected_url( /// the client's connect URL embedded in the signed AUTH event; the helper /// preserves the deployment's TLS posture from `config_relay_url`'s prefix so /// `wss://` deployments stay `wss://` and `ws://` dev/test stays `ws://`. -/// Path is empty — clients put the bare WS origin (`ws://host[:port]`) in the -/// `relay` tag, matching how `EventBuilder::auth` accepts a [`nostr::RelayUrl`]. -pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantContext) -> String { +/// Path is the deployment's `BUZZ_BASE_PATH` prefix and nothing more: clients put +/// their connect URL in the `relay` tag, which is the bare WS origin +/// (`ws://host[:port]`) at the root and `ws://host[:port]/` when the relay +/// is served under one. Matching how `EventBuilder::auth` accepts a +/// [`nostr::RelayUrl`]. +pub(crate) fn nip42_expected_relay_url( + config_relay_url: &str, + base_path: &str, + tenant: &TenantContext, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") { "wss" } else { "ws" }; - format!("{scheme}://{}", tenant.host()) + format!("{scheme}://{}{base_path}", tenant.host()) } /// Extract a channel UUID from a single filter's `#h` tag. @@ -632,7 +645,12 @@ pub async fn submit_event( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/events", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -900,7 +918,12 @@ pub async fn query_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/query", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -1335,7 +1358,12 @@ pub async fn count_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/count", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -1872,6 +1900,26 @@ pub async fn workflow_webhook( } let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); + // SEC-006: the webhook secret authenticates the *caller*, but the run + // executes with the workflow **owner's** standing authority — so the + // secret alone is insufficient. Immediately before run creation, reject + // disabled/inactive workflows and recheck the owner's current channel + // membership (and role, for exfiltration-capable definitions). Fail + // closed with the same generic 404 as the lookups above so a + // revoked-owner workflow is indistinguishable from a nonexistent one. + if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active { + return Err(not_found("workflow not found")); + } + let Some(wf_channel_id) = workflow.channel_id else { + // No channel scope means no channel authority to verify — fail closed. + return Err(not_found("workflow not found")); + }; + state + .workflow_engine + .check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def) + .await + .map_err(|_| not_found("workflow not found"))?; + let run_id = state .db .create_workflow_run(community_id, id, None, trigger_ctx_json.as_ref()) @@ -2046,7 +2094,12 @@ async fn authorize_moderation_read( Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + &path_with_query, + ); let (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?; @@ -2442,7 +2495,7 @@ mod tests { let config_relay_url = "wss://host-a.example"; // doesn't matter — only used for scheme. let tenant_b = fresh_tenant("host-b.example"); - let expected_url = nip98_expected_url(config_relay_url, &tenant_b, "/events"); + let expected_url = nip98_expected_url(config_relay_url, "", &tenant_b, "/events"); let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) .expect_err( @@ -2508,7 +2561,7 @@ mod tests { // tenant host — proving the helper uses `tenant.host()`, not the config. let config_relay_url = "wss://other-config-host.example"; let tenant_a = fresh_tenant("host-a.example"); - let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events"); + 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) @@ -2533,7 +2586,7 @@ mod tests { Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - nip98_expected_url(config_relay_url, tenant, &path_with_query) + nip98_expected_url(config_relay_url, "", tenant, &path_with_query) } /// L7 read-auth blocker (Wren, #1591 sweep): the CLI signs the *full* @@ -2655,15 +2708,15 @@ mod tests { let tenant_a = fresh_tenant("host-a.example"); let tenant_b = fresh_tenant("host-b.example"); - let url_a = nip98_expected_url("wss://config-host.example", &tenant_a, "/events"); - let url_b = nip98_expected_url("wss://config-host.example", &tenant_b, "/events"); + let url_a = nip98_expected_url("wss://config-host.example", "", &tenant_a, "/events"); + let url_b = nip98_expected_url("wss://config-host.example", "", &tenant_b, "/events"); assert_eq!(url_a, "https://host-a.example/events"); assert_eq!(url_b, "https://host-b.example/events"); // Same tenant, two different config hosts → output is identical. // (If config-host ever leaked into the URL, this assertion would bite.) let url_a_alt_config = - nip98_expected_url("wss://different-config.example", &tenant_a, "/events"); + nip98_expected_url("wss://different-config.example", "", &tenant_a, "/events"); assert_eq!( url_a, url_a_alt_config, "config-relay-url's host MUST NOT influence the NIP-98 expected URL — \ @@ -2678,17 +2731,69 @@ mod tests { fn nip98_expected_url_derives_scheme_from_config() { let tenant = fresh_tenant("host-a.example"); assert_eq!( - nip98_expected_url("wss://config.example", &tenant, "/events"), + nip98_expected_url("wss://config.example", "", &tenant, "/events"), "https://host-a.example/events", "wss:// production config → https:// URL" ); assert_eq!( - nip98_expected_url("ws://config.example", &tenant, "/events"), + nip98_expected_url("ws://config.example", "", &tenant, "/events"), "http://host-a.example/events", "ws:// dev config → http:// URL" ); } + /// A relay served under `BUZZ_BASE_PATH` must reconstruct the *prefixed* URL, + /// because that is what the client signed into its NIP-98 `u` tag. Getting + /// this wrong 401s every authenticated HTTP call on a prefixed deployment + /// while looking like a signature failure. + #[test] + fn nip98_expected_url_includes_the_base_path_prefix() { + let tenant = fresh_tenant("host-a.example"); + assert_eq!( + nip98_expected_url("wss://config.example", "/relay", &tenant, "/events"), + "https://host-a.example/relay/events" + ); + assert_eq!( + nip98_expected_url("wss://config.example", "/buzz/relay", &tenant, "/query"), + "https://host-a.example/buzz/relay/query" + ); + assert_eq!( + nip98_expected_url( + "wss://config.example", + "/relay", + &tenant, + "/moderation/audit?limit=20" + ), + "https://host-a.example/relay/moderation/audit?limit=20", + "the prefix precedes the path and the query stays last" + ); + assert_eq!( + nip98_expected_url("wss://config.example", "", &tenant, "/events"), + "https://host-a.example/events", + "an empty prefix is byte-identical to pre-base-path behavior" + ); + } + + /// NIP-42 sibling: the `relay` tag carries the client's connect URL, which + /// includes the prefix when the relay is served under one. + #[test] + fn nip42_expected_relay_url_includes_the_base_path_prefix() { + let tenant = fresh_tenant("host-a.example"); + assert_eq!( + nip42_expected_relay_url("wss://config.example", "/relay", &tenant), + "wss://host-a.example/relay" + ); + assert_eq!( + nip42_expected_relay_url("ws://config.example", "/relay", &tenant), + "ws://host-a.example/relay" + ); + assert_eq!( + nip42_expected_relay_url("wss://config.example", "", &tenant), + "wss://host-a.example", + "an empty prefix is byte-identical to pre-base-path behavior" + ); + } + // ----- NIP-42 host-binding tests (sibling of NIP-98 row 44 obligation) ----- /// Sign a NIP-42 AUTH event with `relay` tag = `relay_url`, then verify @@ -2732,7 +2837,7 @@ mod tests { let config_relay_url = "ws://host-a.example:3100"; let signed_relay_url = "ws://host-a.example:3100"; let tenant_b = fresh_tenant("host-b.example:3100"); - let expected = nip42_expected_relay_url(config_relay_url, &tenant_b); + let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_b); let err = verify_nip42_with_urls(challenge, signed_relay_url, &expected).expect_err( "cross-host NIP-42 AUTH event MUST be rejected — row 44 sibling: \ @@ -2758,7 +2863,7 @@ mod tests { // host — proving the helper uses `tenant.host()`, not the config. let config_relay_url = "ws://other-config-host.example"; let tenant_a = fresh_tenant("host-a.example:3100"); - let expected = nip42_expected_relay_url(config_relay_url, &tenant_a); + let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_a); verify_nip42_with_urls(challenge, signed_relay_url, &expected) .expect("matching-host NIP-42 AUTH event must verify"); @@ -2772,15 +2877,16 @@ mod tests { let tenant_a = fresh_tenant("host-a.example:3100"); let tenant_b = fresh_tenant("host-b.example:3100"); - let url_a = nip42_expected_relay_url("ws://config-host.example", &tenant_a); - let url_b = nip42_expected_relay_url("ws://config-host.example", &tenant_b); + let url_a = nip42_expected_relay_url("ws://config-host.example", "", &tenant_a); + let url_b = nip42_expected_relay_url("ws://config-host.example", "", &tenant_b); assert_eq!(url_a, "ws://host-a.example:3100"); assert_eq!(url_b, "ws://host-b.example:3100"); // Same tenant, two different config hosts → output is identical. // (If config-host ever leaked into the URL, this assertion would bite — // catches the exact "reverted to config host" regression.) - let url_a_alt_config = nip42_expected_relay_url("ws://different-config.example", &tenant_a); + let url_a_alt_config = + nip42_expected_relay_url("ws://different-config.example", "", &tenant_a); assert_eq!( url_a, url_a_alt_config, "config-relay-url's host MUST NOT influence the NIP-42 expected URL — \ @@ -2796,12 +2902,12 @@ mod tests { fn nip42_expected_relay_url_derives_scheme_from_config() { let tenant = fresh_tenant("host-a.example:3100"); assert_eq!( - nip42_expected_relay_url("wss://config.example", &tenant), + nip42_expected_relay_url("wss://config.example", "", &tenant), "wss://host-a.example:3100", "wss:// production config → wss:// URL" ); assert_eq!( - nip42_expected_relay_url("ws://config.example", &tenant), + nip42_expected_relay_url("ws://config.example", "", &tenant), "ws://host-a.example:3100", "ws:// dev config → ws:// URL" ); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 80e74e1ca0e..df5bdd4c3e1 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -63,8 +63,10 @@ const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024; /// Validates the `Authorization: Nostr ` header before the request body /// is read. Same pattern as `AuthenticatedUpload` in media.rs. /// -/// Authorization model: any authenticated pubkey can clone; push authorization -/// is handled by the pre-receive hook (calls back to the internal policy endpoint +/// Authorization model: reads (ref advertisement, upload-pack) require the +/// caller's *current* active membership in the repo's bound channel — see +/// [`authorize_git_read`] (SEC-005). Push authorization is additionally +/// handled by the pre-receive hook (calls back to the internal policy endpoint /// which checks channel role + protection rules from kind:30617). pub struct GitAuth { /// The authenticated user's public key, extracted from the NIP-98 event. @@ -354,6 +356,114 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp .into_response() } +/// SEC-005: authorize a repository *read* (ref advertisement, upload-pack). +/// +/// The authorization invariant is the authenticated git caller's **current +/// active membership in the repo's bound channel**. NIP-98 alone only proves +/// key possession — without this gate any authenticated pubkey (including a +/// member removed from the channel) can clone channel-bound repositories. +/// +/// Resolution follows the current authoritative announcement, the same +/// mapping the push policy endpoint uses: +/// 1. current live kind:30617 by `(community, owner pubkey from the URL, +/// d = canonical repo name)` — soft-deleted/replaced announcements do not +/// resolve; +/// 2. its `buzz-channel` tag → channel UUID; +/// 3. [`buzz_db::Db::get_member_role`] for the caller — a read is allowed +/// only on `Ok(Some(role))` with a role the relay recognizes. +/// +/// Fail-closed: missing/deleted announcement, invalid owner, missing or +/// malformed `buzz-channel` binding, non-member, unknown role, and every DB +/// error all deny. There is deliberately **no repo-owner bypass**: an owner +/// removed from the bound channel loses read access, which is the exact +/// exploit shape this gate closes. Every denial is the same generic 404 as a +/// nonexistent repo so membership cannot be probed through the git endpoints. +async fn authorize_git_read( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + caller: &nostr::PublicKey, + owner_hex: &str, + repo_name: &str, +) -> Result<(), Response> { + fn denied() -> Response { + (StatusCode::NOT_FOUND, "repository not found").into_response() + } + + let Ok(owner_bytes) = hex::decode(owner_hex) else { + return Err(denied()); + }; + if owner_bytes.len() != 32 { + return Err(denied()); + } + + let query = buzz_db::EventQuery { + kinds: Some(vec![30617]), + pubkey: Some(owner_bytes), + d_tag: Some(repo_name.to_string()), + global_only: true, + limit: Some(1), + ..buzz_db::EventQuery::for_community(community) + }; + let repo_event = match db.query_events(&query).await { + Ok(mut events) => match events.pop() { + Some(event) => event, + None => return Err(denied()), + }, + Err(e) => { + error!(repo = %repo_name, error = %e, "git read gate: 30617 lookup failed (deny)"); + return Err(denied()); + } + }; + + let Some(channel_id) = repo_bound_channel_id(&repo_event.event) else { + warn!(repo = %repo_name, "git read gate: missing/malformed buzz-channel binding (deny)"); + return Err(denied()); + }; + + match db + .get_member_role(community, channel_id, &caller.to_bytes()) + .await + { + Ok(role) if read_role_allows(role.as_deref()) => Ok(()), + Ok(_) => Err(denied()), + Err(e) => { + error!(repo = %repo_name, error = %e, "git read gate: role lookup failed (deny)"); + Err(denied()) + } + } +} + +/// Extract the `buzz-channel` UUID from a kind:30617 announcement. +/// +/// First-tag semantics, matching the push policy endpoint: only the *first* +/// `buzz-channel` tag is considered, and it must carry a valid UUID. A +/// malformed first binding denies even if a later duplicate tag is valid — +/// an ambiguous announcement must fail closed, not silently resolve to +/// whichever duplicate happens to parse. +fn repo_bound_channel_id(event: &nostr::Event) -> Option { + let first = event + .tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel"))?; + first + .as_slice() + .get(1) + .and_then(|v| uuid::Uuid::parse_str(v).ok()) +} + +/// Pure decision for [`authorize_git_read`]: a read requires a current +/// active membership row whose role the relay recognizes. +/// +/// `None` = not an active member (removed, left, never joined) ⇒ deny. +/// An unrecognized role string ⇒ deny (fail-closed, same as the push +/// policy endpoint). +fn read_role_allows(role: Option<&str>) -> bool { + match role { + Some(r) => r.parse::().is_ok(), + None => false, + } +} + #[derive(Deserialize)] /// Query parameters for the `info/refs` endpoint. pub struct InfoRefsQuery { @@ -547,7 +657,19 @@ pub async fn info_refs( "git-upload-pack" | "git-receive-pack" => &query.service, _ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()), }; - let _repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + + // SEC-005: channel-membership gate before any manifest load, hydration, + // or subprocess work. Both services — the receive-pack advertisement + // leaks the ref list just like the upload-pack one. + authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.pubkey, + ¶ms.owner, + repo_name, + ) + .await?; // Track C fast path: only for clone advertisement. The receive-pack // advertisement carries a different capability set (report-status, @@ -790,7 +912,21 @@ pub async fn upload_pack( AxumPath(params): AxumPath, body: Body, ) -> Result { - let _ = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + + // SEC-005: the reused NIP-98 token means the GET advertisement's + // authorization cannot stand in for POST-time membership — gate this + // door independently, before body decode work is driven or hydration + // starts. + authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.pubkey, + ¶ms.owner, + repo_name, + ) + .await?; + let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES); let permit = acquire_git_permit(&state, "upload_pack")?; @@ -2286,3 +2422,335 @@ mod track_c_tests { assert!(caps.contains("object-format=sha256")); } } + +#[cfg(test)] +mod sec005_read_gate_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + // ── Pure decision helpers ──────────────────────────────────────────── + + #[test] + fn read_role_allows_every_recognized_role() { + for role in ["owner", "admin", "member", "guest", "bot"] { + assert!(read_role_allows(Some(role)), "role {role:?} must allow"); + } + } + + #[test] + fn read_role_denies_non_members_and_unknown_roles() { + assert!(!read_role_allows(None), "no membership row must deny"); + assert!( + !read_role_allows(Some("superuser")), + "unrecognized role must deny (fail-closed)" + ); + assert!(!read_role_allows(Some("")), "empty role must deny"); + } + + fn announcement(keys: &Keys, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(30617), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign 30617") + } + + #[test] + fn repo_bound_channel_id_extracts_valid_uuid() { + let keys = Keys::generate(); + let ch = uuid::Uuid::new_v4(); + let event = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&event), Some(ch)); + } + + #[test] + fn repo_bound_channel_id_rejects_absent_and_malformed_bindings() { + let keys = Keys::generate(); + let absent = announcement(&keys, vec![Tag::parse(["d", "r"]).unwrap()]); + assert_eq!(repo_bound_channel_id(&absent), None); + + let malformed = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&malformed), None); + + let empty = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel"]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&empty), None); + } + + #[test] + fn repo_bound_channel_id_fails_closed_on_ambiguous_duplicate_bindings() { + // First-tag semantics: a malformed first binding must deny even when + // a later duplicate tag is valid. An ambiguous announcement must not + // silently resolve to whichever duplicate happens to parse. + let keys = Keys::generate(); + let ch = uuid::Uuid::new_v4(); + let malformed_first = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&malformed_first), None); + + // And the mirror image: a valid first binding wins, matching the + // push policy endpoint's first-tag resolution. + let other = uuid::Uuid::new_v4(); + let valid_first = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + Tag::parse(["buzz-channel", &other.to_string()]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&valid_first), Some(ch)); + } + + // ── authorize_git_read matrix (requires Postgres) ──────────────────── + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_db() -> buzz_db::Db { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + buzz_db::Db::from_pool(pool) + } + + /// How the fixture's kind:30617 binds (or fails to bind) a channel. + enum Binding { + /// `buzz-channel` tag carrying the fixture channel's UUID. + Channel, + /// No `buzz-channel` tag at all. + Missing, + /// `buzz-channel` tag whose value is not a UUID. + Malformed, + } + + struct RepoFixture { + db: buzz_db::Db, + community: buzz_core::CommunityId, + channel: uuid::Uuid, + owner_keys: Keys, + owner_hex: String, + member_keys: Keys, + repo: String, + } + + /// Community + channel + one plain member + a kind:30617 announcement. + /// The repo owner is a *different* key that is not a channel member — + /// deliberately, to pin "no repo-owner bypass". + async fn setup_repo(binding: Binding) -> RepoFixture { + let db = setup_db().await; + let host = format!("sec005-{}.example", uuid::Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + + let owner_keys = Keys::generate(); + let member_keys = Keys::generate(); + let creator = Keys::generate(); // channel creator, distinct from repo owner + let creator_pk = creator.public_key().to_bytes().to_vec(); + let member_pk = member_keys.public_key().to_bytes().to_vec(); + db.ensure_user(community, &creator_pk).await.expect("user"); + db.ensure_user(community, &member_pk).await.expect("user"); + + let channel = uuid::Uuid::new_v4(); + db.create_channel_with_id( + community, + channel, + &format!("ch-{}", channel.simple()), + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &creator_pk, + None, + ) + .await + .expect("channel"); + db.add_member( + community, + channel, + &member_pk, + buzz_core::channel::MemberRole::Member, + Some(&creator_pk), + ) + .await + .expect("member"); + + let repo = format!("repo-{}", uuid::Uuid::new_v4().simple()); + let mut tags = vec![Tag::parse(["d", &repo]).unwrap()]; + match binding { + Binding::Channel => { + tags.push(Tag::parse(["buzz-channel", &channel.to_string()]).unwrap()); + } + Binding::Missing => {} + Binding::Malformed => { + tags.push(Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap()); + } + } + let event = announcement(&owner_keys, tags); + db.insert_event(community, &event, None) + .await + .expect("30617"); + + let owner_hex = owner_keys.public_key().to_hex(); + RepoFixture { + db, + community, + channel, + owner_keys, + owner_hex, + member_keys, + repo, + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_gate_allows_current_member_denies_removed_and_owner_bypass() { + let f = setup_repo(Binding::Channel).await; + + // Current member: allowed. + let member = f.member_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_ok(), + "current member must be allowed to read" + ); + + // Never-a-member caller: denied. + let stranger = Keys::generate().public_key(); + assert!( + authorize_git_read(&f.db, f.community, &stranger, &f.owner_hex, &f.repo) + .await + .is_err(), + "non-member must be denied" + ); + + // Member removed → denied. THE finding-005 exploit shape. + let member_pk = member.to_bytes().to_vec(); + f.db.remove_member(f.community, f.channel, &member_pk, &member_pk) + .await + .expect("self-remove"); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_err(), + "removed member must be denied" + ); + + // No repo-owner bypass: the announcement author is not a channel + // member and must be denied too. + let owner = f.owner_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &owner, &f.owner_hex, &f.repo) + .await + .is_err(), + "repo owner outside the channel must be denied (no owner bypass)" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_gate_denies_missing_or_malformed_binding_and_absent_repo() { + // Missing buzz-channel tag → deny even for a channel member. + let f = setup_repo(Binding::Missing).await; + let member = f.member_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_err(), + "announcement without buzz-channel binding must deny" + ); + + // Malformed buzz-channel tag → deny. + let g = setup_repo(Binding::Malformed).await; + let member_g = g.member_keys.public_key(); + assert!( + authorize_git_read(&g.db, g.community, &member_g, &g.owner_hex, &g.repo) + .await + .is_err(), + "announcement with malformed buzz-channel binding must deny" + ); + + // Nonexistent announcement → deny. + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo") + .await + .is_err(), + "nonexistent repo must deny" + ); + + // Owner-mismatch: URL owner differs from announcement author → deny. + let impostor_hex = Keys::generate().public_key().to_hex(); + assert!( + authorize_git_read(&f.db, f.community, &member, &impostor_hex, &f.repo) + .await + .is_err(), + "URL owner that never announced this repo must deny" + ); + + // Invalid owner hex in URL → deny (never panics). + assert!( + authorize_git_read(&f.db, f.community, &member, "zz-not-hex", &f.repo) + .await + .is_err(), + "malformed owner hex must deny" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_gate_follows_current_announcement_not_stale_registry() { + // Max's registry/pointer concern: a soft-deleted 30617 can leave the + // `git_repo_names` reservation and the manifest pointer alive. Reads + // must follow the current authoritative announcement — once it's + // deleted, the gate denies even a current channel member. + let f = setup_repo(Binding::Channel).await; + + let member = f.member_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_ok(), + "precondition: member allowed while announcement is live" + ); + + let owner_pk = f.owner_keys.public_key().to_bytes().to_vec(); + let deleted = + f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo) + .await + .expect("soft delete 30617"); + assert!(deleted, "precondition: a live announcement row was deleted"); + + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_err(), + "deleted announcement must deny reads even for channel members" + ); + } +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6719e85f791..0315c843391 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -25,7 +25,12 @@ use serde::Deserialize; use serde_json::Value; use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; -use crate::invite_token::{self, DEFAULT_INVITE_TTL_SECS}; +use buzz_core::invite::{ + hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, + MIN_INVITE_TTL_SECS, V2_PREFIX, +}; + +use crate::invite_token; use crate::state::AppState; use super::{api_error, bridge, internal_error}; @@ -43,10 +48,42 @@ pub(crate) const CLAIM_RATE_CACHE_CAPACITY: u64 = 10_000; /// Body for `POST /api/invites`. #[derive(Debug, Default, Deserialize)] pub struct MintInviteRequest { - /// Requested lifetime in seconds. Clamped to + /// Requested lifetime in seconds. Must be between + /// [`MIN_INVITE_TTL_SECS`] and /// [`invite_token::MAX_INVITE_TTL_SECS`]; defaults to 72 h. #[serde(default)] pub ttl_secs: Option, + /// Maximum number of uses before the invite is exhausted. `None` (omitted + /// or `null`) means unlimited — preserves current behavior. When present, + /// must be an integer from 1 through [`MAX_INVITE_USES`]. + #[serde(default)] + pub max_uses: Option, +} + +fn validate_mint_request( + request: &MintInviteRequest, +) -> Result<(u64, Option), (StatusCode, Json)> { + let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS); + if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "ttl_secs must be between {} and {MAX_INVITE_TTL_SECS}", + MIN_INVITE_TTL_SECS + ), + )); + } + + if let Some(max_uses) = request.max_uses { + if !(1..=MAX_INVITE_USES).contains(&max_uses) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("max_uses must be between 1 and {MAX_INVITE_USES}"), + )); + } + } + + Ok((ttl, request.max_uses)) } /// Body for `POST /api/invites/claim`. @@ -209,7 +246,12 @@ async fn authenticate( ) })?; - let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let url = bridge::nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + path, + ); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, "POST", @@ -260,9 +302,17 @@ pub async fn mint_invite( })? }; - let key = invite_token::derive_invite_key(&state.relay_keypair); - let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS); - let (code, expires_at) = invite_token::mint_invite(&key, tenant.community(), ttl); + let (ttl, max_uses) = validate_mint_request(&request)?; + + // Mint a v2 opaque, database-backed invite. + let invite = state + .db + .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) + .await + .map_err(|error| match error { + buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), + error => internal_error(&format!("invite mint: {error}")), + })?; // Same TLS-posture logic as nip98_expected_url: wss deployments get an // https landing page URL, ws dev/test deployments get http. @@ -275,19 +325,30 @@ pub async fn mint_invite( tracing::info!( community = %tenant.community(), minted_by = %sender_hex, - expires_at, + invite_id = %invite.invite_id, + expires_at = %invite.expires_at, + max_uses = ?invite.max_uses, "relay invite minted" ); + // expires_at as unix seconds for the response contract. + let expires_at_unix = invite.expires_at.timestamp() as u64; + Ok(Json(serde_json::json!({ - "code": code, - "expires_at": expires_at, - "url": format!("{scheme}://{}/invite/{}", tenant.host(), code), + "code": invite.code, + "expires_at": expires_at_unix, + "max_uses": invite.max_uses, + "uses_remaining": invite.uses_remaining, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), }))) } /// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the /// *joining* pubkey. Exempt from the relay-membership gate by design. +/// +/// Routing is by exact prefix: `v2.` codes go to the database-backed +/// redemption path; every other code goes to the v1 HMAC verifier. A `v2.` +/// code is never fallen back to v1 verification. pub async fn claim_invite( State(state): State>, headers: HeaderMap, @@ -305,7 +366,88 @@ pub async fn claim_invite( let request: ClaimInviteRequest = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?; + let claimer_hex = pubkey.to_hex(); let key = invite_token::derive_invite_key(&state.relay_keypair); + + // --- v2 database-backed path --- + // + // Route by exact prefix: v2. codes use the durable invite table. No + // fallback to v1 HMAC verification for malformed v2 input. + if request.code.starts_with(V2_PREFIX) { + validate_v2_code(&request.code) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_invalid"))?; + + // Join-policy receipt verification, same mechanism as v1: the receipt + // is bound to the code string by SHA-256, so it works for v2 codes. + if let Some(policy) = &state.config.join_policy { + let receipt = request + .policy_receipt + .as_deref() + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; + invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; + } + + let token_hash = hash_v2_code(&request.code); + let outcome = state + .db + .claim_relay_invite( + tenant.community(), + &token_hash, + &claimer_hex, + state + .config + .join_policy + .as_ref() + .map(|policy| policy.version.as_str()), + ) + .await + .map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?; + + return match outcome { + buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { + tracing::info!( + community = %tenant.community(), + member = %claimer_hex, + "relay member added via v2 invite" + ); + // NIP-43 side effects only on Joined, never on other outcomes. + if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { + tracing::warn!( + "failed to publish NIP-43 member-added delta after v2 claim: {e}" + ); + } + if let Err(e) = publish_nip43_membership_list(&tenant, &state).await { + tracing::warn!("failed to publish NIP-43 membership list after v2 claim: {e}"); + } + Ok(Json(serde_json::json!({ + "status": "joined", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }))) + } + buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => { + Ok(Json(serde_json::json!({ + "status": "already_member", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }))) + } + buzz_db::relay_invite::ClaimOutcome::Expired => { + Err(api_error(StatusCode::FORBIDDEN, "invite_expired")) + } + buzz_db::relay_invite::ClaimOutcome::Exhausted => { + Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")) + } + buzz_db::relay_invite::ClaimOutcome::Invalid => { + Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) + } + }; + } + + // --- v1 HMAC path (stateless tokens, drain window) --- let payload = invite_token::verify_invite(&key, tenant.community(), &request.code).map_err( |e| match e { // Expired is post-MAC: revealing it helps the UX without helping a forger. @@ -317,7 +459,6 @@ pub async fn claim_invite( }, )?; - let claimer_hex = pubkey.to_hex(); if let Some(policy) = &state.config.join_policy { let receipt = request .policy_receipt @@ -395,7 +536,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT}; + use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT, MAX_INVITE_USES, MIN_INVITE_TTL_SECS}; use axum::{ body::{to_bytes, Body}, http::{header, Request, StatusCode}, @@ -409,7 +550,7 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; - use crate::invite_token::{derive_invite_key, InvitePayload}; + use crate::invite_token::{derive_invite_key, InvitePayload, MAX_INVITE_TTL_SECS}; use crate::router::build_router; use crate::state::AppState; @@ -592,6 +733,355 @@ mod tests { serde_json::from_slice(&bytes).expect("response JSON") } + async fn mint_code(state: Arc, host: &str, owner: &Keys, request: Value) -> String { + let response = post_json(state, host, "/api/invites", owner, request.to_string()).await; + assert_eq!(response.status(), StatusCode::OK); + read_json(response) + .await + .get("code") + .and_then(Value::as_str) + .expect("minted code") + .to_string() + } + + async fn event_count(state: &AppState, community: buzz_core::CommunityId, kind: i32) -> i64 { + state + .db + .count_events(&buzz_db::EventQuery { + kinds: Some(vec![kind]), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("count side-effect events") + } + + #[test] + fn mint_request_deserialization_is_strict() { + for valid in [ + serde_json::json!({}), + serde_json::json!({ "max_uses": null }), + serde_json::json!({ "max_uses": 1 }), + serde_json::json!({ "max_uses": MAX_INVITE_USES }), + ] { + serde_json::from_value::(valid).expect("valid request"); + } + + for invalid in [ + serde_json::json!({ "max_uses": 1.5 }), + serde_json::json!({ "max_uses": "10" }), + serde_json::json!({ "ttl_secs": -1 }), + serde_json::json!({ "ttl_secs": 1.5 }), + serde_json::json!({ "ttl_secs": "3600" }), + ] { + assert!( + serde_json::from_value::(invalid.clone()).is_err(), + "accepted wrong JSON type: {invalid}" + ); + } + } + + #[test] + fn mint_request_validation_enforces_bounds_without_a_database() { + use super::validate_mint_request; + + for (request, expected) in [ + ( + super::MintInviteRequest::default(), + (crate::invite_token::DEFAULT_INVITE_TTL_SECS, None), + ), + ( + super::MintInviteRequest { + ttl_secs: Some(MIN_INVITE_TTL_SECS), + max_uses: Some(1), + }, + (MIN_INVITE_TTL_SECS, Some(1)), + ), + ( + super::MintInviteRequest { + ttl_secs: Some(MAX_INVITE_TTL_SECS), + max_uses: Some(MAX_INVITE_USES), + }, + (MAX_INVITE_TTL_SECS, Some(MAX_INVITE_USES)), + ), + ] { + assert_eq!( + validate_mint_request(&request).expect("valid request"), + expected + ); + } + + for request in [ + super::MintInviteRequest { + ttl_secs: None, + max_uses: Some(0), + }, + super::MintInviteRequest { + ttl_secs: None, + max_uses: Some(-1), + }, + super::MintInviteRequest { + ttl_secs: None, + max_uses: Some(MAX_INVITE_USES + 1), + }, + super::MintInviteRequest { + ttl_secs: Some(MIN_INVITE_TTL_SECS - 1), + max_uses: None, + }, + super::MintInviteRequest { + ttl_secs: Some(MAX_INVITE_TTL_SECS + 1), + max_uses: None, + }, + ] { + assert_eq!( + validate_mint_request(&request) + .expect_err("invalid request") + .0, + StatusCode::BAD_REQUEST + ); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn mint_validates_max_uses_and_ttl_bounds() { + let host = format!("invites-validation-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + + for body in [ + serde_json::json!({ "max_uses": 0 }), + serde_json::json!({ "max_uses": -1 }), + serde_json::json!({ "max_uses": MAX_INVITE_USES + 1 }), + serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS - 1 }), + serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS + 1 }), + ] { + let response = post_json( + state.clone(), + &host, + "/api/invites", + &owner, + body.to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{body}"); + } + + for body in [ + serde_json::json!({}), + serde_json::json!({ "max_uses": null }), + serde_json::json!({ "max_uses": 1 }), + serde_json::json!({ "max_uses": MAX_INVITE_USES }), + serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS }), + serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS }), + ] { + let response = post_json( + state.clone(), + &host, + "/api/invites", + &owner, + body.to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "{body}"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn malformed_and_unknown_v2_codes_are_forbidden_without_v1_fallback() { + let host = format!("invites-v2-invalid-{}.example", Uuid::new_v4().simple()); + let joiner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let unknown = format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 32])); + + for code in [ + "v2.".to_string(), + "v2.not-base64!".to_string(), + format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 31])), + unknown, + ] { + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code }).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{code}"); + assert_eq!( + read_json(response) + .await + .get("error") + .and_then(Value::as_str), + Some("invite_invalid") + ); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bounded_v2_claims_publish_side_effects_only_for_joined() { + let host = format!( + "invites-v2-side-effects-{}.example", + Uuid::new_v4().simple() + ); + let owner = Keys::generate(); + let first = Keys::generate(); + let second = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + let before_delta_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await; + let before_list_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await; + let code = mint_code( + state.clone(), + &host, + &owner, + serde_json::json!({ "max_uses": 1 }), + ) + .await; + let claim_body = serde_json::json!({ "code": code }).to_string(); + + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &first, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + read_json(response) + .await + .get("status") + .and_then(Value::as_str), + Some("joined") + ); + let delta_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await; + let list_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await; + assert_eq!(delta_count, before_delta_count + 1); + assert_eq!(list_count, before_list_count + 1); + + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &first, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + read_json(response) + .await + .get("status") + .and_then(Value::as_str), + Some("already_member") + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await, + delta_count + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await, + list_count + ); + + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &second, + claim_body, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + read_json(response) + .await + .get("error") + .and_then(Value::as_str), + Some("invite_exhausted") + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await, + delta_count + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await, + list_count + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn owner_mints_and_new_pubkey_claims() { diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc262..a7478c7d916 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -402,6 +402,7 @@ pub async fn upload_blob( rewrite_descriptor_urls_for_tenant( &mut descriptor, &state.config.relay_url, + &state.config.base_path, auth.tenant.host(), ); @@ -444,7 +445,17 @@ pub async fn upload_blob( Ok(Json(descriptor)) } -pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &str) -> String { +/// Build the tenant-scoped media base URL that upload descriptors advertise. +/// +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). The prefix must be present: this URL is what clients and +/// agents fetch, and it is embedded in published events, so a missing prefix +/// bakes an unreachable URL into message history rather than failing loudly. +pub(crate) fn media_base_url_for_tenant( + config_relay_url: &str, + base_path: &str, + tenant_host: &str, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") || config_relay_url.trim_start().starts_with("https://") { @@ -452,15 +463,16 @@ pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &st } else { "http" }; - format!("{scheme}://{tenant_host}/media") + format!("{scheme}://{tenant_host}{base_path}/media") } fn rewrite_descriptor_urls_for_tenant( descriptor: &mut BlobDescriptor, config_relay_url: &str, + base_path: &str, tenant_host: &str, ) { - let base = media_base_url_for_tenant(config_relay_url, tenant_host); + let base = media_base_url_for_tenant(config_relay_url, base_path, tenant_host); let ext = descriptor .url .rsplit_once('.') @@ -1271,15 +1283,69 @@ mod tests { #[test] fn media_base_url_for_tenant_uses_tenant_host_and_http_scheme() { assert_eq!( - media_base_url_for_tenant("wss://config.example", "tenant-b.example"), + media_base_url_for_tenant("wss://config.example", "", "tenant-b.example"), "https://tenant-b.example/media" ); assert_eq!( - media_base_url_for_tenant("ws://config.example", "localhost:3100"), + media_base_url_for_tenant("ws://config.example", "", "localhost:3100"), "http://localhost:3100/media" ); } + /// A relay served under BUZZ_BASE_PATH must advertise media under the prefix. + /// This URL is embedded in published events and fetched by clients and agents, + /// so dropping the prefix bakes an unreachable URL into message history — and + /// behind a path-routing gateway it resolves to the gateway, not the relay. + #[test] + fn media_base_url_for_tenant_includes_the_base_path_prefix() { + assert_eq!( + media_base_url_for_tenant("wss://config.example", "/relay", "tenant-b.example"), + "https://tenant-b.example/relay/media" + ); + assert_eq!( + media_base_url_for_tenant("wss://config.example", "/buzz/relay", "tenant-b.example"), + "https://tenant-b.example/buzz/relay/media" + ); + assert_eq!( + media_base_url_for_tenant("ws://config.example", "/relay", "localhost:3100"), + "http://localhost:3100/relay/media" + ); + } + + #[test] + fn rewrite_descriptor_urls_for_tenant_applies_the_base_path_prefix() { + let hash = "b".repeat(64); + let mut descriptor = BlobDescriptor { + url: format!("https://primary.example/media/{hash}.png"), + sha256: hash.clone(), + size: 7, + mime_type: "image/png".to_string(), + uploaded: 1700000000, + dim: None, + blurhash: None, + thumb: Some(format!("https://primary.example/media/{hash}.thumb.jpg")), + duration: None, + }; + + rewrite_descriptor_urls_for_tenant( + &mut descriptor, + "wss://primary.example", + "/relay", + "tenant-b.example", + ); + + assert_eq!( + descriptor.url, + format!("https://tenant-b.example/relay/media/{hash}.png") + ); + assert_eq!( + descriptor.thumb, + Some(format!( + "https://tenant-b.example/relay/media/{hash}.thumb.jpg" + )) + ); + } + #[test] fn rewrite_descriptor_urls_for_tenant_replaces_global_media_host() { let hash = "a".repeat(64); @@ -1298,6 +1364,7 @@ mod tests { rewrite_descriptor_urls_for_tenant( &mut descriptor, "wss://primary.example", + "", "tenant-b.example", ); diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874c..8073068ce50 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -74,7 +74,11 @@ async fn authorize_operator_request( Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - let url = format!("{origin}{path_with_query}"); + // `RELAY_OPERATOR_API_ORIGIN` is validated to carry no path, so the + // deployment's `BUZZ_BASE_PATH` prefix is applied here — the operator's + // signed `u` tag is the URL they actually called, prefix included. + let base_path = state.config.base_path.as_str(); + let url = format!("{origin}{base_path}{path_with_query}"); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, method, diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c7..9598e16bc4f 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -216,7 +216,11 @@ 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 relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); + let relay_url = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + ); let auth_ctx = match state .auth .verify_auth_event(auth_msg.event, &challenge, &relay_url) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 47030dcf3f0..13dc7301972 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -64,8 +64,37 @@ pub struct Config { /// pod is only 4 — small enough that rate-limit checks, presence, and /// pub/sub publishes queue behind each other under load. pub redis_pool_size: usize, + /// Maximum connections in the Postgres writer/reader pools. Defaults to 50. + /// + /// The `buzz-db` default of 20 was sized for a handful of pods against + /// `max_connections=100`. Against Aurora (~5,000 connections) that cap + /// is the binding constraint: a burst of concurrent handlers exhausts + /// the per-pod pool and requests fail on acquire timeout while the + /// database sits idle. + pub db_pool_size: u32, /// Public WebSocket URL of this relay, advertised in NIP-11. pub relay_url: String, + /// URL path prefix the relay is served under (`BUZZ_BASE_PATH`). + /// + /// Empty by default: every route mounts at the root and behavior is + /// identical to deployments that never set this. When non-empty, the whole + /// HTTP + WebSocket surface nests under the prefix, so the relay can sit + /// behind a gateway that routes by path instead of by hostname — the + /// WebSocket lands on `wss://host/` and the bridge endpoints on + /// `https://host//events` and friends. + /// + /// Normalized at load time to exactly one leading slash and no trailing + /// slash (`relay`, `/relay`, and `/relay/` all become `/relay`). The + /// prefix participates in NIP-98 `u` tags and the NIP-42 `relay` tag, so + /// it must match what clients connect to byte-for-byte. `RELAY_URL` and + /// `BUZZ_MEDIA_BASE_URL` must carry the same prefix. + /// + /// Known limitation: the browser web bundle (`BUZZ_WEB_DIR`) references its + /// assets at absolute `/assets/...` URLs baked in at build time, so serving + /// that SPA under a prefix additionally requires rebuilding it with a + /// matching Vite `base`. The relay's protocol surface — WebSocket, bridge, + /// media, git — is unaffected. + pub base_path: String, /// Public WebSocket URL of the dedicated device-pairing relay, when configured. pub pairing_relay_url: Option, /// Maximum number of concurrent WebSocket connections. @@ -267,6 +296,47 @@ fn parse_bind_addr(raw: &str) -> Result { .map_err(|e| ConfigError::InvalidBindAddr(e.to_string())) } +/// Normalize a `BUZZ_BASE_PATH` value to the canonical form the router and the +/// auth-URL builders expect: either empty (mount at the root) or exactly one +/// leading slash with no trailing slash. +/// +/// The prefix is concatenated into signed-URL expectations (NIP-98 `u`, NIP-42 +/// `relay`), so anything that could make the server's reconstruction differ from +/// the client's connect URL is rejected at startup rather than surfacing later +/// as an unexplainable 401. That means no query, fragment, whitespace, dot +/// segments, or empty interior segments. +pub(crate) fn normalize_base_path(raw: &str) -> Result { + let trimmed = raw.trim(); + let stripped = trimmed.trim_matches('/'); + if stripped.is_empty() { + // Unset, blank, and a bare "/" all mean "serve at the root". + return Ok(String::new()); + } + + let invalid = |reason: &str| { + Err(ConfigError::InvalidValue(format!( + "BUZZ_BASE_PATH {reason} (got {raw:?})" + ))) + }; + + if stripped.contains('?') || stripped.contains('#') { + return invalid("must not contain a query string or fragment"); + } + if stripped.chars().any(char::is_whitespace) { + return invalid("must not contain whitespace"); + } + for segment in stripped.split('/') { + if segment.is_empty() { + return invalid("must not contain empty path segments"); + } + if segment == "." || segment == ".." { + return invalid("must not contain dot segments"); + } + } + + Ok(format!("/{stripped}")) +} + fn positive_u64_from_env(name: &str, default: u64) -> Result { match std::env::var(name) { Ok(raw) => raw @@ -424,9 +494,20 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(16); + let db_pool_size = std::env::var("BUZZ_DB_POOL_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(50); + let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); + let base_path = match std::env::var("BUZZ_BASE_PATH") { + Ok(raw) => normalize_base_path(&raw)?, + Err(_) => String::new(), + }; + let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL") .ok() .map(|value| value.trim().to_string()) @@ -875,7 +956,9 @@ impl Config { read_database_url, redis_url, redis_pool_size, + db_pool_size, relay_url, + base_path, pairing_relay_url, max_connections, max_concurrent_handlers, @@ -934,14 +1017,93 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn base_path_normalizes_equivalent_spellings() { + for raw in ["relay", "/relay", "relay/", "/relay/", " /relay/ "] { + assert_eq!( + normalize_base_path(raw).expect("valid prefix"), + "/relay", + "{raw:?} should normalize to /relay" + ); + } + assert_eq!( + normalize_base_path("/buzz/relay").expect("valid prefix"), + "/buzz/relay", + "multi-segment prefixes are preserved" + ); + } + + #[test] + fn base_path_treats_blank_and_root_as_unprefixed() { + for raw in ["", " ", "/", "//"] { + assert_eq!( + normalize_base_path(raw).expect("valid prefix"), + "", + "{raw:?} should mean serve at the root" + ); + } + } + + #[test] + fn base_path_rejects_values_that_would_break_signed_urls() { + // Each of these would make the server's URL reconstruction differ from + // the client's connect URL, surfacing as an unexplainable 401. + for raw in [ + "/relay?x=1", + "/relay#frag", + "/re lay", + "/relay//inner", + "/relay/../etc", + "/./relay", + ] { + let err = normalize_base_path(raw).expect_err("should be rejected"); + assert!( + matches!(err, ConfigError::InvalidValue(_)), + "{raw:?} should be an InvalidValue error, got {err:?}" + ); + } + } + + #[test] + fn base_path_defaults_to_empty_when_unset() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_BASE_PATH"); + std::env::remove_var("BUZZ_BASE_PATH"); + let config = Config::from_env().expect("default config"); + assert_eq!( + config.base_path, "", + "an unset BUZZ_BASE_PATH must leave routes at the root" + ); + + std::env::set_var("BUZZ_BASE_PATH", "relay/"); + let prefixed = Config::from_env().expect("prefixed config"); + assert_eq!(prefixed.base_path, "/relay"); + + std::env::set_var("BUZZ_BASE_PATH", "/relay?x=1"); + assert!( + Config::from_env().is_err(), + "an invalid prefix must fail startup, not degrade silently" + ); + + match previous { + Some(value) => std::env::set_var("BUZZ_BASE_PATH", value), + None => std::env::remove_var("BUZZ_BASE_PATH"), + } + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); let config = Config::from_env().expect("default config"); + assert_eq!( + config.base_path, "", + "base_path should default to empty (root-mounted)" + ); assert!(config.bind_addr.port() > 0); assert!(!config.database_url.is_empty()); assert!(!config.redis_url.is_empty()); assert_eq!(config.redis_pool_size, 16); + assert_eq!(config.db_pool_size, 50); assert!(config.max_connections > 0); assert!(config.send_buffer_size > 0); assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); @@ -1009,6 +1171,31 @@ mod tests { assert_eq!(junk, 16, "unparsable value must fall back to the default"); } + #[test] + fn db_pool_size_env_override_and_invalid_fallback() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_DB_POOL_SIZE"); + + std::env::set_var("BUZZ_DB_POOL_SIZE", "80"); + let overridden = Config::from_env().expect("config").db_pool_size; + + std::env::set_var("BUZZ_DB_POOL_SIZE", "0"); + let zero = Config::from_env().expect("config").db_pool_size; + + std::env::set_var("BUZZ_DB_POOL_SIZE", "not-a-number"); + let junk = Config::from_env().expect("config").db_pool_size; + + if let Some(value) = previous { + std::env::set_var("BUZZ_DB_POOL_SIZE", value); + } else { + std::env::remove_var("BUZZ_DB_POOL_SIZE"); + } + + assert_eq!(overridden, 80); + assert_eq!(zero, 50, "zero must fall back to the default"); + assert_eq!(junk, 50, "unparsable value must fall back to the default"); + } + #[test] fn read_database_url_unset_or_blank_is_none() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e0..ccc0990e9ba 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -77,8 +77,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); - let relay_url = - crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); + let relay_url = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &state.config.base_path, + &conn.tenant, + ); let auth_svc = Arc::clone(&state.auth); metrics::counter!("buzz_auth_attempts_total", "method" => "nip42").increment(1); diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index bd3eff4de9b..2d82736807a 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -685,6 +685,24 @@ async fn handle_workflow_def( .map_err(|e| IngestError::Rejected(format!("invalid: workflow YAML parse error: {e}")))?; let workflow_name = extract_tag(event, "name").unwrap_or_else(|| def.name.clone()); + // SEC-006: definitions with exfiltration-capable actions (call_webhook) + // require elevated channel authority to save — plain membership is not + // enough, because the workflow will forward channel content outward with + // the owner's standing authority. Fail-closed on lookup errors. + if def.requires_elevated_authority() { + let role = state + .db + .get_member_role(tenant.community(), channel_id, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: role check: {e}")))?; + if !matches!(role.as_deref(), Some("owner") | Some("admin")) { + return Err(IngestError::Rejected( + "forbidden: workflows with call_webhook actions require the owner or admin role" + .into(), + )); + } + } + let mut definition_json: serde_json::Value = serde_json::from_str(&definition_json_str) .map_err(|e| IngestError::Internal(format!("error: json parse of definition: {e}")))?; @@ -843,6 +861,31 @@ async fn handle_workflow_trigger( )); } + // SEC-006: manual triggers must honor the workflow's lifecycle state and + // recheck the owner's *current* channel authority before creating a run. + // Without this, a disabled workflow — including one disabled because its + // owner was removed from the channel — could still be fired by the owner. + if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active { + return Err(IngestError::Rejected( + "forbidden: workflow is disabled or inactive".into(), + )); + } + let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) + .map_err(|e| IngestError::Internal(format!("error: corrupt workflow definition: {e}")))?; + let Some(wf_channel_id) = workflow.channel_id else { + // No channel scope means no channel authority to verify — fail closed. + return Err(IngestError::Rejected( + "forbidden: workflow has no channel scope".into(), + )); + }; + state + .workflow_engine + .check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def) + .await + .map_err(|_| { + IngestError::Rejected("forbidden: not authorized to trigger this workflow".into()) + })?; + // Persist the command event under the workflow channel even though the // trigger event itself only carries the workflow UUID. Storing channel // triggers as global events leaks workflow IDs to unrelated relay members. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 497adc6c737..1457cb64b20 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -183,6 +183,19 @@ pub enum IngestError { Internal(String), } +fn map_relay_admin_error(error: super::relay_admin::RelayAdminError) -> IngestError { + use super::relay_admin::RelayAdminError; + match error { + // Same wire prefix and HTTP status (403) as every other durable + // restriction refusal — see the write-path gate below and `auth.rs`. + RelayAdminError::Banned => { + IngestError::AuthFailed("blocked: you are banned from this community".to_string()) + } + RelayAdminError::Rejected(reason) => IngestError::Rejected(format!("invalid: {reason}")), + RelayAdminError::Internal(reason) => IngestError::Internal(format!("error: {reason}")), + } +} + fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { match error { super::push_lease::AcceptError::Validation(reason) => { @@ -1624,7 +1637,10 @@ async fn ingest_event_inner( // is the durable backstop the fan-out's best-effort delivery relies on. // Moderation commands enforce bans inside their handler and remain exempt // here only so timeouts do not disarm the tool used to lift them. Relay-admin - // commands retain their separate authorization policy. + // commands (9030–9033) are exempt for the same reason — a timed-out admin + // must still be able to administer the roster — and likewise enforce the + // durable ban inside `relay_admin::handle_relay_admin_event`. Any kind added + // to this exemption owes the same handler-local ban check. // // Scope: this gate checks the *authoring* pubkey only, with no NIP-OA // owner→agent cascade. That cascade lives at the auth seam for bans, where @@ -1831,10 +1847,13 @@ async fn ingest_event_inner( } // Handled directly — these mutate relay_members and do NOT get stored. + // The handler enforces the durable community ban itself: the write-path + // gate above exempts relay-admin kinds so timed-out admins keep their + // administrative capability, which leaves bans to the handler. if is_relay_admin_kind(event.kind.as_u16() as u32) { crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) .await - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + .map_err(map_relay_admin_error)?; return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2234,8 +2253,11 @@ async fn ingest_event_inner( .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) .collect(); if !imeta_tags.is_empty() { - let tenant_media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + let tenant_media_base = crate::api::media::media_base_url_for_tenant( + &state.config.relay_url, + &state.config.base_path, + tenant.host(), + ); crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage) @@ -2542,6 +2564,62 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + /// A banned relay admin must be refused with the same wire prefix and + /// transport status as every other durable-restriction refusal: + /// `blocked:` and (via `bridge.rs`'s `AuthFailed` arm) HTTP 403 — never + /// `invalid:`/400, which reads as "your request was malformed" and lets a + /// client retry-loop against an authorization decision. + #[test] + fn relay_admin_ban_maps_to_blocked_auth_failure() { + let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Banned); + match mapped { + IngestError::AuthFailed(msg) => { + assert_eq!(msg, "blocked: you are banned from this community"); + } + other => panic!("banned admin must map to AuthFailed (HTTP 403), got {other:?}"), + } + } + + /// Validation/authorization failures keep the pre-existing `invalid:` + /// prefix and 400 status — this is the arm the whole 9030-series relied on + /// before the ban category existed, so it must not regress. + #[test] + fn relay_admin_rejection_keeps_invalid_prefix() { + let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Rejected( + "actor not authorized: must be admin or owner".to_string(), + )); + match mapped { + IngestError::Rejected(msg) => { + assert_eq!( + msg, "invalid: actor not authorized: must be admin or owner", + "existing relay-admin rejections must keep their exact wire text" + ); + } + other => panic!("validation failure must map to Rejected, got {other:?}"), + } + } + + /// A restriction-lookup outage is a server fault, not a client one. It + /// must fail closed as `error:`/500 so a Postgres blip can neither admit a + /// banned admin nor be reported to an innocent one as a bad request. + #[test] + fn relay_admin_internal_maps_to_error_not_client_fault() { + let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Internal( + "internal error checking restriction state: pool timed out".to_string(), + )); + match mapped { + IngestError::Internal(msg) => { + assert!( + msg.starts_with("error: "), + "internal failures need the `error:` NIP-01 prefix, got {msg:?}" + ); + } + other => { + panic!("restriction DB failure must map to Internal (HTTP 500), got {other:?}") + } + } + } + #[derive(Debug, Default)] struct VecTracer { steps: Mutex>, diff --git a/crates/buzz-relay/src/handlers/product_feedback.rs b/crates/buzz-relay/src/handlers/product_feedback.rs index 92d045e1947..c11b1f57c2e 100644 --- a/crates/buzz-relay/src/handlers/product_feedback.rs +++ b/crates/buzz-relay/src/handlers/product_feedback.rs @@ -27,8 +27,11 @@ pub async fn handle( .map(|tag| tag.as_slice().iter().map(ToString::to_string).collect()) .collect::>>(); if !imeta_tags.is_empty() { - let media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + let media_base = crate::api::media::media_base_url_for_tenant( + &state.config.relay_url, + &state.config.base_path, + tenant.host(), + ); crate::api::validate_imeta_tags(&imeta_tags, &media_base)?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage).await?; } diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 840d9dcfe58..3f58a9c2aa8 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -94,8 +94,92 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { Ok(()) } +/// A relay-admin command failure, carrying the *category* of the failure so +/// the ingest seam can map it to the right NIP-01 prefix and HTTP status. +/// +/// The category is part of the security contract, not cosmetics: a ban must +/// surface as `blocked:` / HTTP 403 (like every other durable-restriction +/// refusal), and a restriction-lookup outage must surface as a 500 rather than +/// a client-side 400 that reads as "your request was malformed". Mirrors +/// [`super::push_lease::AcceptError`] and its `map_push_accept_error` seam. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum RelayAdminError { + /// Sender is under a durable community ban — `blocked:` / HTTP 403. + Banned, + /// Legacy command rejection — `invalid:` / HTTP 400. This is whatever + /// [`execute_relay_admin_command`] returned as a `String`, which is mostly + /// validation and authorization but also still includes that function's own + /// DB failures. Categorizing those is deliberately out of scope for the ban + /// fix, so this arm claims no stronger invariant than "the command body said + /// no". + Rejected(String), + /// Admission could not be decided because the restriction lookup failed — + /// `error:` / HTTP 500. + Internal(String), +} + +/// Decide whether a durable restriction state admits a relay-admin command. +/// +/// Ban only, deliberately: a timeout is a write-block on *content*, and +/// `ingest_event` exempts relay-admin kinds from its durable write-path gate +/// precisely so restricted-but-not-banned admins retain their administrative +/// capability. Mirrors `moderation_commands::ensure_actor_not_banned`. +/// +/// Split out as a pure function so the admission rule itself is unit-testable +/// without a live relay: the end-to-end HTTP test proves the transport, this +/// proves the decision. +fn admits_relay_admin_command( + restriction: &buzz_db::moderation::RestrictionState, +) -> Result<(), RelayAdminError> { + if restriction.banned { + return Err(RelayAdminError::Banned); + } + Ok(()) +} + /// Validate and execute a relay admin command (kinds 9030–9033). /// +/// Admission: rejects a sender under a durable community ban before any +/// command runs. The command itself is executed by +/// [`execute_relay_admin_command`]. +/// +/// Returns `Ok(())` on success, or a categorized [`RelayAdminError`]. +pub(super) async fn handle_relay_admin_event( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result<(), RelayAdminError> { + // A ban is an admission boundary, not only a WebSocket-auth check. HTTP + // NIP-98 requests and already-authenticated sockets reach this handler + // without passing through a fresh NIP-42 challenge, and `ingest_event` + // exempts relay-admin kinds from its durable write-path gate so a *timed + // out* admin can still administer the roster. That exemption is ban-blind, + // so the ban must be enforced here or not at all: a banned admin otherwise + // keeps mutating `relay_members` — the very table `moderation_authz` + // derives moderator capability from — until someone manually deletes the + // row. Mirrors `moderation_commands.rs`, which defends the same boundary + // for 9040–9044, and holds that file's stated invariant that a direct + // command handler rejects a banned actor on every transport. + // + // This gate wraps execution rather than opening it so no future early + // return inside the command body can precede it. + let restriction = state + .db + .moderation_restriction_state(tenant.community(), &event.pubkey.to_bytes()) + .await + // Fail closed: a DB blip must never admit a banned admin. + .map_err(|e| { + RelayAdminError::Internal(format!("internal error checking restriction state: {e}")) + })?; + admits_relay_admin_command(&restriction)?; + + execute_relay_admin_command(tenant, state, event) + .await + .map_err(RelayAdminError::Rejected) +} + +/// Execute an already-admitted relay admin command. +/// /// The handler: /// 1. Extracts the target pubkey from the `["p", ...]` tag. /// 2. Extracts the role from the `["role", ...]` tag (kinds 9030 and 9032). @@ -103,9 +187,11 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { /// 4. Enforces the permission matrix. /// 5. Applies the change via the DB. /// -/// Returns `Ok(())` on success. Returns `Err(msg)` — where `msg` is a -/// human-readable rejection reason — on any validation failure. -pub async fn handle_relay_admin_event( +/// Returns `Ok(())` on success. Returns `Err(msg)` — where `msg` is the +/// legacy rejection reason — on any failure. This body does not distinguish +/// validation failures from execution DB failures; both surface as `Err(msg)` +/// and are categorised by the caller as [`RelayAdminError::Rejected`]. +async fn execute_relay_admin_command( tenant: &TenantContext, state: &Arc, event: &Event, @@ -349,6 +435,46 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; + /// The vulnerability this file's ban gate closes: `ingest_event` exempts + /// relay-admin kinds 9030–9033 from its durable write-path restriction + /// gate, so a **banned** admin could add/remove relay members and change + /// the workspace icon over signed NIP-98 `POST /events`. Deleting the + /// admission check must fail here, in the default (non-ignored) suite. + #[test] + fn banned_actor_is_not_admitted_to_a_relay_admin_command() { + let banned = buzz_db::moderation::RestrictionState { + banned: true, + muted_until: None, + }; + assert_eq!( + admits_relay_admin_command(&banned), + Err(RelayAdminError::Banned), + "a durably banned admin must never reach a relay-admin command" + ); + } + + /// The counter-invariant, and the reason the ingest exemption exists at + /// all: a timeout restricts *content* writes, not administrative + /// capability. Widening this gate to timeouts would silently change policy. + #[test] + fn timed_out_actor_is_still_admitted() { + let timed_out = buzz_db::moderation::RestrictionState { + banned: false, + muted_until: Some(chrono::Utc::now() + chrono::Duration::minutes(5)), + }; + assert!( + admits_relay_admin_command(&timed_out).is_ok(), + "a timed-out admin must still administer the roster" + ); + } + + #[test] + fn unrestricted_actor_is_admitted() { + assert!( + admits_relay_admin_command(&buzz_db::moderation::RestrictionState::default()).is_ok() + ); + } + /// Build a minimal signed Event with the given kind and tags. /// The pubkey will be randomly generated — sufficient for tag extraction tests. fn make_test_event(kind: u16, tags: Vec>) -> Event { diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 6ff1315acc7..65d04ef0bac 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -51,6 +51,52 @@ async fn evict_live_channel_subscriptions( } } +/// Durably disable a departing member's workflows in the channel (SEC-006). +/// +/// A workflow runs with its owner's standing authority; once the owner is no +/// longer a member (removed via kind 9001 or left via kind 9022) their +/// workflows must stop firing on every path — event triggers, the scheduler, +/// manual triggers, and the webhook endpoint all honor `enabled = FALSE`. +/// The per-fire authority gate in `buzz-workflow` is the fail-closed backstop; +/// this makes the revocation durable and immediately visible. +/// +/// Failures are logged, not propagated: membership removal has already been +/// committed, and the per-fire gate still denies a removed owner even if this +/// disable write is lost. +async fn disable_departed_member_workflows( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + target_pubkey: &[u8], +) { + match state + .db + .disable_workflows_for_owner_in_channel(tenant.community(), channel_id, target_pubkey) + .await + { + Ok(0) => {} + Ok(n) => { + tracing::info!( + channel = %channel_id, + owner = %hex::encode(target_pubkey), + disabled = n, + "Disabled departed member's workflows" + ); + state + .workflow_engine + .invalidate_channel_workflows(tenant.community(), channel_id); + } + Err(e) => { + warn!( + channel = %channel_id, + owner = %hex::encode(target_pubkey), + error = %e, + "Failed to disable departed member's workflows — per-fire authority gate still denies" + ); + } + } +} + /// Close every live channel-scoped subscription on `conn_id`, removing them from /// the connection's local map and sending `CLOSED restricted` for each. async fn evict_conn_channel_subscriptions( @@ -1341,6 +1387,7 @@ async fn handle_remove_user( .await?; state.invalidate_membership(tenant, channel_id, &target_pubkey); evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await; + disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await; let actor_hex = hex::encode(&actor_bytes); let target_hex = hex::encode(&target_pubkey); @@ -1987,6 +2034,7 @@ async fn handle_leave_request( .await?; state.invalidate_membership(tenant, channel_id, &actor_bytes); evict_live_channel_subscriptions(tenant, state, channel_id, &actor_bytes).await; + disable_departed_member_workflows(tenant, state, channel_id, &actor_bytes).await; let actor_hex = hex::encode(&actor_bytes); emit_system_message( diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs index 436040c32a6..e271f40f0b5 100644 --- a/crates/buzz-relay/src/invite_token.rs +++ b/crates/buzz-relay/src/invite_token.rs @@ -49,10 +49,10 @@ use buzz_core::tenant::CommunityId; type HmacSha256 = Hmac; /// Default invite lifetime: 72 hours. -pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60; +pub use buzz_core::invite::DEFAULT_INVITE_TTL_SECS; /// Maximum invite lifetime a mint request may ask for: 30 days. -pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60; +pub use buzz_core::invite::MAX_INVITE_TTL_SECS; /// Maximum accepted code length (defense against absurd inputs before any /// parsing work happens). A real code is ~200 bytes. @@ -121,10 +121,11 @@ fn sign_payload(key: &[u8; 32], payload_bytes: &[u8]) -> Vec { mac.finalize().into_bytes().to_vec() } -/// Mint an invite code for `community`, expiring `ttl_secs` from now. +/// Mint a legacy v1 invite code for compatibility tests. /// -/// The role is fixed to `"member"` — elevated roles are granted post-join via -/// the existing kind:9032 change-role command, never via a bearer link. +/// Production minting uses database-backed v2 codes. Remove this helper with +/// v1 claim verification after the compatibility drain window. +#[cfg(test)] pub fn mint_invite(key: &[u8; 32], community: CommunityId, ttl_secs: u64) -> (String, u64) { let ttl = ttl_secs.clamp(60, MAX_INVITE_TTL_SECS); let expires_at = now_unix() + ttl; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 22101219ebf..3ed820d3c50 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -4,6 +4,10 @@ use std::sync::Arc; use tracing::{error, info, warn}; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +fn log_env_filter(rust_log: Option<&str>) -> EnvFilter { + EnvFilter::new(rust_log.unwrap_or("buzz_relay=info")) +} use uuid::Uuid; use buzz_audit::AuditService; @@ -107,9 +111,17 @@ async fn main() -> anyhow::Result<()> { }; tracing_subscriber::registry() - .with(fmt::layer().json().flatten_event(true)) - .with(EnvFilter::from_default_env().add_directive("buzz_relay=info".parse()?)) - .with(otel_layer) + .with( + fmt::layer() + .json() + .flatten_event(true) + .with_filter(log_env_filter(std::env::var("RUST_LOG").ok().as_deref())), + ) + .with(otel_layer.map(|layer| { + layer.with_filter(telemetry::otel_env_filter( + std::env::var("BUZZ_OTEL_FILTER").ok().as_deref(), + )) + })) .init(); // Log any exporter-build failure now that the subscriber is installed. @@ -146,6 +158,7 @@ async fn main() -> anyhow::Result<()> { let db_config = DbConfig { database_url: config.database_url.clone(), read_database_url: config.read_database_url.clone(), + max_connections: config.db_pool_size, ..DbConfig::default() }; let db = Db::new(&db_config).await.map_err(|e| { @@ -1060,6 +1073,52 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +#[cfg(test)] +mod env_filter_tests { + use super::log_env_filter; + use buzz_relay::telemetry::otel_env_filter; + use tracing_subscriber::prelude::*; + + #[test] + fn unset_enables_datastore_only_for_otel_filter() { + let logs = tracing_subscriber::registry().with(log_env_filter(None)); + tracing::subscriber::with_default(logs, || { + assert!(!tracing::enabled!(target: "buzz_datastore", tracing::Level::INFO)); + assert!(tracing::enabled!(target: "buzz_relay", tracing::Level::INFO)); + }); + + let otel = tracing_subscriber::registry().with(otel_env_filter(None)); + tracing::subscriber::with_default(otel, || { + assert!(tracing::enabled!(target: "buzz_datastore", tracing::Level::INFO)); + }); + } + + #[test] + fn explicit_datastore_off_is_preserved_alone() { + assert_eq!( + otel_env_filter(Some("buzz_datastore=off")).to_string(), + "buzz_datastore=off" + ); + } + + #[test] + fn explicit_datastore_debug_is_preserved_alone() { + assert_eq!( + otel_env_filter(Some("buzz_datastore=debug")).to_string(), + "buzz_datastore=debug" + ); + } + + #[test] + fn log_and_otel_filters_are_configured_independently() { + assert_eq!(log_env_filter(Some("warn")).to_string(), "warn"); + assert_eq!( + otel_env_filter(Some("buzz_relay=debug")).to_string(), + "buzz_relay=debug" + ); + } +} + async fn run_community_revalidator( state: Arc, period: std::time::Duration, @@ -1426,6 +1485,20 @@ async fn run_usage_metrics_tick( *leader = None; return Err(error); } + let invite_retention_cutoff = chrono::Utc::now() - chrono::Duration::days(30); + match state + .db + .reap_expired_relay_invites(invite_retention_cutoff) + .await + { + Ok(deleted) if deleted > 0 => { + info!(deleted, "reaped expired relay invites"); + } + Ok(_) => {} + Err(error) => { + warn!(error = %error, "failed to reap expired relay invites"); + } + } run_storage_sweep_tick(state, emission_scope, &host_map).await; } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21f..75e93012ae8 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -180,9 +180,14 @@ pub async fn relay_info_handler( axum::response::Json(nip11_document(&state, raw_host).await) } +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). Push clients dial the advertised `origin` directly, so a +/// prefixed deployment must advertise the prefix or every push registration +/// lands on a path the gateway does not route. fn push_descriptor( push_configured: bool, relay_url: &str, + base_path: &str, executor_key_id: &str, relay_keypair: &nostr::Keys, tenant_host: Option<&str>, @@ -195,7 +200,7 @@ fn push_descriptor( "ws" }; Some(serde_json::json!({ - "origin": format!("{scheme}://{host}"), + "origin": format!("{scheme}://{host}{base_path}"), "keys": [{ "id": executor_key_id, "pubkey": relay_keypair.public_key().to_hex(), @@ -253,6 +258,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st if let Some(push) = push_descriptor( state.config.push_gateway_delivery_url.is_some(), &state.config.relay_url, + &state.config.base_path, &state.config.push_executor_key_id, &state.relay_keypair, tenant_host.as_deref(), @@ -341,12 +347,19 @@ mod tests { #[test] fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { let keys = nostr::Keys::generate(); - assert!( - push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none() - ); - assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none()); - let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example")) - .expect("configured push descriptor"); + assert!(push_descriptor( + false, + "ws://relay", + "", + "key", + &keys, + Some("tenant.example") + ) + .is_none()); + assert!(push_descriptor(true, "ws://relay", "", "key", &keys, None).is_none()); + let descriptor = + push_descriptor(true, "ws://relay", "", "key", &keys, Some("tenant.example")) + .expect("configured push descriptor"); assert_eq!(descriptor["origin"], "ws://tenant.example"); assert_eq!( descriptor["push_kinds"], @@ -354,6 +367,23 @@ mod tests { ); } + /// Push clients dial the advertised `origin` verbatim, so a relay served + /// under `BUZZ_BASE_PATH` has to advertise the prefix. + #[test] + fn push_descriptor_origin_carries_the_base_path_prefix() { + let keys = nostr::Keys::generate(); + let descriptor = push_descriptor( + true, + "wss://relay", + "/relay", + "key", + &keys, + Some("tenant.example"), + ) + .expect("configured push descriptor"); + assert_eq!(descriptor["origin"], "wss://tenant.example/relay"); + } + #[test] fn supported_nips_includes_nip23_and_nip33() { // Tests the production SUPPORTED_NIPS constant directly — no Config::from_env() diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 2af036079ff..2004a4c6ac3 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -4,8 +4,9 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use axum::{ + body::Body, extract::{ConnectInfo, FromRequest, State, WebSocketUpgrade}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, Request, StatusCode}, middleware, response::{IntoResponse, Json}, routing::{get, post, put}, @@ -16,7 +17,7 @@ use tower::ServiceExt; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; -use tower_http::trace::TraceLayer; +use tower_http::trace::{HttpMakeClassifier, TraceLayer}; use crate::api; use crate::audio; @@ -185,12 +186,60 @@ pub fn build_router(state: Arc) -> Router { merged = merged.fallback_service(spa_fallback); } - merged + // Optional path prefix (`BUZZ_BASE_PATH`). Empty is the default and leaves + // the router exactly as built above. When set, the whole surface nests + // under the prefix so the relay can live behind a gateway that routes by + // path rather than by hostname. + // + // `nest` strips the prefix before the inner router sees the request, so + // every route match, the SPA fallback's `req.uri().path()` checks, and the + // media/git sub-routers keep working against their unprefixed paths. + // + // Health probes stay mounted at the root as well: Kubernetes probes reach + // the pod directly rather than through the gateway that needs the prefix, + // and `deploy/compose` curls `/_liveness` on the published port. + let root_health = Router::new() + .route("/health", get(health_handler)) + .route("/_liveness", get(liveness_handler)) + .route("/_readiness", get(readiness_handler)) + .with_state(state.clone()); + let routed = nest_under_base_path(&state.config.base_path, merged, root_health); + + routed .layer(middleware::from_fn(track_metrics)) - .layer(TraceLayer::new_for_http()) + .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } +/// Mount `router` under `base_path`, keeping `root_extras` reachable at the root. +/// +/// An empty `base_path` returns `router` untouched — the default, and byte-identical +/// to the pre-`BUZZ_BASE_PATH` router. Otherwise everything nests under the prefix +/// so the relay can sit behind a gateway that routes by path rather than hostname. +/// `nest` strips the prefix before the inner router sees the request, so route +/// matches and the SPA fallback's own `req.uri().path()` checks keep working +/// against their unprefixed paths. +fn nest_under_base_path(base_path: &str, router: Router, root_extras: Router) -> Router { + if base_path.is_empty() { + router + } else { + Router::new().nest(base_path, router).merge(root_extras) + } +} + +fn http_trace_layer() -> TraceLayer) -> tracing::Span> { + TraceLayer::new_for_http().make_span_with(make_http_span as fn(&Request) -> tracing::Span) +} + +fn make_http_span(request: &Request) -> tracing::Span { + tracing::info_span!( + target: "buzz_relay", + "http.request", + otel.kind = "server", + http.request.method = %request.method(), + ) +} + fn is_admin_spa_path(path: &str) -> bool { path == "/" || path == "/reports" @@ -435,9 +484,14 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { mod tests { use axum::{routing::get, Router}; use futures_util::SinkExt; + use opentelemetry::trace::TracerProvider as _; + use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; + use tower::ServiceBuilder; + use tracing::Instrument as _; + use tracing_subscriber::prelude::*; use super::*; @@ -471,6 +525,133 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + /// Build the two-router pair `build_router` hands to [`nest_under_base_path`], + /// standing in for the real relay surface and the root-mounted health probes. + fn base_path_fixture(base_path: &str) -> Router { + // The real surface already carries the health probes (they live in + // `api_router`), so `root_extras` is a deliberate duplicate that only + // matters once the surface moves under a prefix. + let surface = Router::new() + .route("/", get(|| async { "ws-or-nip11" })) + .route("/events", get(|| async { "events" })) + .route("/_liveness", get(|| async { "ok" })); + let root_extras = Router::new().route("/_liveness", get(|| async { "ok" })); + nest_under_base_path(base_path, surface, root_extras) + } + + async fn status_of(router: Router, path: &str) -> StatusCode { + let request = Request::builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + router + .oneshot(request) + .await + .expect("router response") + .status() + } + + #[tokio::test(flavor = "current_thread")] + async fn empty_base_path_leaves_every_route_at_the_root() { + let router = base_path_fixture(""); + assert_eq!(status_of(router.clone(), "/").await, StatusCode::OK); + assert_eq!(status_of(router.clone(), "/events").await, StatusCode::OK); + assert_eq!(status_of(router, "/_liveness").await, StatusCode::OK); + } + + #[tokio::test(flavor = "current_thread")] + async fn base_path_moves_the_surface_under_the_prefix() { + let router = base_path_fixture("/relay"); + // The WebSocket/NIP-11 route answers on the bare prefix, which is what a + // client connecting to wss://host/relay asks for. + assert_eq!(status_of(router.clone(), "/relay").await, StatusCode::OK); + assert_eq!( + status_of(router.clone(), "/relay/events").await, + StatusCode::OK + ); + // Root probes stay reachable: Kubernetes hits the pod directly, bypassing + // the gateway that requires the prefix. + assert_eq!( + status_of(router.clone(), "/_liveness").await, + StatusCode::OK + ); + // Unprefixed application paths no longer resolve — the gateway owns the + // root, and a stale client hitting it should fail loudly, not silently + // reach a half-configured relay. + assert_eq!( + status_of(router.clone(), "/events").await, + StatusCode::NOT_FOUND + ); + assert_eq!(status_of(router, "/other").await, StatusCode::NOT_FOUND); + } + + #[tokio::test(flavor = "current_thread")] + async fn multi_segment_base_path_routes() { + let router = base_path_fixture("/buzz/relay"); + assert_eq!( + status_of(router.clone(), "/buzz/relay").await, + StatusCode::OK + ); + assert_eq!( + status_of(router, "/buzz/relay/events").await, + StatusCode::OK + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn http_and_datastore_spans_are_exported_in_the_same_trace() { + let exporter = InMemorySpanExporter::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with( + tracing_opentelemetry::layer() + .with_tracer(provider.tracer("test")) + .with_filter(crate::telemetry::otel_env_filter(None)), + ); + let _subscriber_guard = tracing::subscriber::set_default(subscriber); + let service = ServiceBuilder::new() + .layer(http_trace_layer()) + .service(tower::service_fn( + |_: axum::http::Request| async { + async {} + .instrument(tracing::info_span!( + target: "buzz_datastore", + "SELECT", + otel.kind = "client", + db.system.name = "postgresql", + )) + .await; + Ok::<_, std::convert::Infallible>(axum::response::Response::new( + axum::body::Body::empty(), + )) + }, + )); + + service + .oneshot( + axum::http::Request::get("/") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + let http = spans + .iter() + .find(|span| span.name == "http.request") + .unwrap(); + let datastore = spans.iter().find(|span| span.name == "SELECT").unwrap(); + + assert_eq!( + datastore.span_context.trace_id(), + http.span_context.trace_id() + ); + assert_eq!(datastore.parent_span_id, http.span_context.span_id()); + } + async fn handler_receives_message_with_limit(limit: usize, size: usize) -> bool { let (received_tx, mut received_rx) = mpsc::unbounded_channel(); let app = Router::new().route( diff --git a/crates/buzz-relay/src/subscription.rs b/crates/buzz-relay/src/subscription.rs index 68f0fea3c40..7a62188d3a6 100644 --- a/crates/buzz-relay/src/subscription.rs +++ b/crates/buzz-relay/src/subscription.rs @@ -164,17 +164,30 @@ impl SubscriptionRegistry { conn_id: ConnId, sub_id: &str, ) -> Option { - if let Some(mut conn_subs) = self.subs.get_mut(&conn_id) { - if let Some((filters, community_id, channel_id)) = conn_subs.remove(sub_id) { - self.remove_from_index(conn_id, sub_id, &filters, community_id, channel_id); - metrics::gauge!("buzz_subscriptions_active").decrement(1.0); - return Some(RemovedSubscription { - community_id, - channel_id, - }); - } - } - None + self.remove_subscription_inner(conn_id, sub_id, || {}) + } + + fn remove_subscription_inner( + &self, + conn_id: ConnId, + sub_id: &str, + after_remove: F, + ) -> Option + where + F: FnOnce(), + { + let mut conn_subs = self.subs.get_mut(&conn_id)?; + let (filters, community_id, channel_id) = conn_subs.remove(sub_id)?; + + after_remove(); + self.remove_from_index(conn_id, sub_id, &filters, community_id, channel_id); + drop(conn_subs); + + metrics::gauge!("buzz_subscriptions_active").decrement(1.0); + Some(RemovedSubscription { + community_id, + channel_id, + }) } /// Remove all subscriptions for a connection and clean up index entries. @@ -275,15 +288,37 @@ impl SubscriptionRegistry { channel_id, kind: event.event.kind, }; - if let Some(candidates) = self.channel_kind_index.get(&(community_id, key)) { - for (conn_id, sub_id) in candidates.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(candidates) = self + .channel_kind_index + .get(&(community_id, key)) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in candidates { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } // Also check wildcard (channel-only, kindless) index. - if let Some(wildcards) = self.channel_wildcard_index.get(&(community_id, channel_id)) { - for (conn_id, sub_id) in wildcards.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(wildcards) = self + .channel_wildcard_index + .get(&(community_id, channel_id)) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in wildcards { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } } else { @@ -296,24 +331,54 @@ impl SubscriptionRegistry { kind: event.event.kind, p, }; - if let Some(candidates) = self.global_p_kind_index.get(&key) { - for (conn_id, sub_id) in candidates.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(candidates) = self + .global_p_kind_index + .get(&key) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in candidates { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } } if let Some(candidates) = self .global_kind_index .get(&(community_id, event.event.kind)) + .map(|entry| entry.value().clone()) { - for (conn_id, sub_id) in candidates.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + for (conn_id, sub_id) in candidates { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } // Also check global wildcard (kindless global subs). - if let Some(wildcards) = self.global_wildcard_index.get(&community_id) { - for (conn_id, sub_id) in wildcards.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(wildcards) = self + .global_wildcard_index + .get(&community_id) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in wildcards { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } } @@ -370,13 +435,20 @@ impl SubscriptionRegistry { &self, conn_id: ConnId, sub_id: &str, + community_id: CommunityId, event: &StoredEvent, results: &mut Vec<(ConnId, SubId)>, seen: &mut HashSet<(ConnId, SubId)>, ) { if let Some(conn_subs) = self.subs.get(&conn_id) { - if let Some((filters, _, _)) = conn_subs.get(sub_id) { - if filters_match(filters, event) { + if let Some((filters, sub_community_id, sub_channel_id)) = conn_subs.get(sub_id) { + // Candidate snapshots can become stale while a same-ID replacement + // moves the subscription. Re-check its authoritative scope before + // matching so an old index entry cannot deliver across scopes. + if *sub_community_id == community_id + && *sub_channel_id == event.channel_id + && filters_match(filters, event) + { let entry = (conn_id, sub_id.to_string()); if seen.insert(entry.clone()) { results.push(entry); @@ -576,6 +648,8 @@ mod tests { use buzz_core::StoredEvent; use chrono::Utc; use nostr::{EventBuilder, Keys, Kind, Tag}; + use std::sync::Arc; + use std::time::{Duration, Instant}; fn make_stored_event(kind: Kind, channel_id: Option) -> StoredEvent { let keys = Keys::generate(); @@ -629,6 +703,141 @@ mod tests { assert!(matches.is_empty()); } + #[test] + fn test_subscription_removal_cannot_delete_replacement_index() { + let registry = Arc::new(SubscriptionRegistry::new()); + let conn_id = Uuid::new_v4(); + let channel_id = Uuid::new_v4(); + let sub_id = "same-id".to_string(); + let filters = vec![Filter::new().kind(Kind::TextNote)]; + registry.register(conn_id, sub_id.clone(), filters.clone(), Some(channel_id)); + + let (removed_tx, removed_rx) = std::sync::mpsc::sync_channel(0); + let (resume_tx, resume_rx) = std::sync::mpsc::sync_channel(0); + let remove_registry = Arc::clone(®istry); + let remove_sub_id = sub_id.clone(); + let remove = std::thread::spawn(move || { + remove_registry.remove_subscription_inner(conn_id, &remove_sub_id, || { + removed_tx.send(()).expect("signal authoritative removal"); + resume_rx.recv().expect("resume index cleanup"); + }) + }); + + removed_rx.recv().expect("old subscription removed"); + + let (registered_tx, registered_rx) = std::sync::mpsc::sync_channel(0); + let register_registry = Arc::clone(®istry); + let register_sub_id = sub_id.clone(); + let register = std::thread::spawn(move || { + register_registry.register(conn_id, register_sub_id, filters, Some(channel_id)); + registered_tx + .send(()) + .expect("signal replacement registration"); + }); + + let replacement_finished_early = registered_rx + .recv_timeout(Duration::from_millis(100)) + .is_ok(); + resume_tx.send(()).expect("resume old cleanup"); + remove.join().expect("removal thread completes"); + if !replacement_finished_early { + registered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("replacement registration completes"); + } + register.join().expect("registration thread completes"); + assert!( + !replacement_finished_early, + "replacement must wait until old index cleanup is complete" + ); + + let event = make_stored_event(Kind::TextNote, Some(channel_id)); + assert_eq!( + registry.fan_out(&event), + vec![(conn_id, sub_id)], + "replacement must remain reachable through its index" + ); + } + + #[test] + fn test_stale_candidate_snapshot_does_not_cross_subscription_scope() { + let registry = SubscriptionRegistry::new(); + let conn_id = Uuid::new_v4(); + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let sub_id = "same-id".to_string(); + let filters = vec![Filter::new().kind(Kind::TextNote)]; + registry.register(conn_id, sub_id.clone(), filters.clone(), Some(channel_a)); + + // Reproduce fan-out's unlocked candidate snapshot, then move the same + // subscription ID before the authoritative subscription lookup. + let key = IndexKey { + channel_id: channel_a, + kind: Kind::TextNote, + }; + let candidates = registry + .channel_kind_index + .get(&(test_community(), key)) + .expect("channel A candidate exists") + .value() + .clone(); + registry.register(conn_id, sub_id, filters, Some(channel_b)); + + let event = make_stored_event(Kind::TextNote, Some(channel_a)); + let mut results = Vec::new(); + let mut seen = HashSet::new(); + for (candidate_conn_id, candidate_sub_id) in candidates { + registry.push_match( + candidate_conn_id, + &candidate_sub_id, + test_community(), + &event, + &mut results, + &mut seen, + ); + } + + assert!( + results.is_empty(), + "replacement on channel B received channel A event through stale snapshot" + ); + } + + #[test] + fn test_fan_out_concurrent_with_subscription_replacement_completes() { + let registry = Arc::new(SubscriptionRegistry::new()); + let conn_id = Uuid::new_v4(); + let channel_id = Uuid::new_v4(); + let sub_id = "sub1".to_string(); + let filters = vec![Filter::new().kind(Kind::TextNote)]; + registry.register(conn_id, sub_id.clone(), filters.clone(), Some(channel_id)); + let event = Arc::new(make_stored_event(Kind::TextNote, Some(channel_id))); + let deadline = Instant::now() + Duration::from_secs(2); + + let fan_out_registry = Arc::clone(®istry); + let fan_out_event = Arc::clone(&event); + let fan_out = std::thread::spawn(move || { + while Instant::now() < deadline { + let _ = fan_out_registry.fan_out(&fan_out_event); + } + }); + + let replace_registry = Arc::clone(®istry); + let replace = std::thread::spawn(move || { + while Instant::now() < deadline { + replace_registry.register( + conn_id, + sub_id.clone(), + filters.clone(), + Some(channel_id), + ); + } + }); + + fan_out.join().expect("fan-out thread completes"); + replace.join().expect("replacement thread completes"); + } + #[test] fn test_subscription_registry_remove_connection() { let registry = SubscriptionRegistry::new(); diff --git a/crates/buzz-relay/src/telemetry.rs b/crates/buzz-relay/src/telemetry.rs index 8dd305cd422..11c6d035128 100644 --- a/crates/buzz-relay/src/telemetry.rs +++ b/crates/buzz-relay/src/telemetry.rs @@ -25,6 +25,16 @@ use opentelemetry_otlp::ExporterBuildError; use opentelemetry_sdk::{resource::EnvResourceDetector, trace::SdkTracerProvider, Resource}; +use tracing_subscriber::EnvFilter; + +/// Build the filter for spans exported through OpenTelemetry. +/// +/// This is intentionally independent from `RUST_LOG`: changing stdout log +/// verbosity must not remove parent spans from exported traces. Set +/// `BUZZ_OTEL_FILTER` to override the default targets. +pub fn otel_env_filter(configured: Option<&str>) -> EnvFilter { + EnvFilter::new(configured.unwrap_or("buzz_relay=info,buzz_datastore=info")) +} /// Build the OTEL [`Resource`] used by the trace provider. /// diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c52..8cc9c8650a9 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1580,6 +1580,22 @@ pub fn build_presence_update(status: &str) -> Result { Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags)) } +/// Build a NIP-38 user status event (kind 30315) on the `d:general` coordinate. +/// +/// `text` becomes the event content and `emoji`, when non-blank, an +/// `["emoji", ...]` tag; both are trimmed. Blank text with no emoji clears the +/// status — kind 30315 is parameterized-replaceable, so an event carrying +/// neither is what clients read as "no status". +pub fn build_user_status(text: &str, emoji: Option<&str>) -> Result { + let text = text.trim(); + check_content(text, 64 * 1024)?; + let mut tags = vec![tag(&["d", "general"])?]; + if let Some(emoji) = emoji.map(str::trim).filter(|e| !e.is_empty()) { + tags.push(tag(&["emoji", emoji])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_USER_STATUS as u16), text).tags(tags)) +} + // --------------------------------------------------------------------------- // Community moderation commands (kinds 9040–9044). // @@ -3391,6 +3407,53 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + // ── build_user_status ───────────────────────────────────────────────────── + + #[test] + fn user_status_carries_text_and_emoji_on_d_general() { + let ev = sign(build_user_status("shipping the CLI", Some("🚀")).unwrap()); + assert_eq!(ev.kind.as_u16(), 30315); + assert_eq!(ev.content, "shipping the CLI"); + assert_eq!(tag_values(&ev, "d"), vec!["general"]); + assert_eq!(tag_values(&ev, "emoji"), vec!["🚀"]); + } + + #[test] + fn user_status_trims_text_and_emoji() { + let ev = sign(build_user_status(" heads down ", Some(" 🎧 ")).unwrap()); + assert_eq!(ev.content, "heads down"); + assert_eq!(tag_values(&ev, "emoji"), vec!["🎧"]); + } + + #[test] + fn user_status_omits_blank_emoji_tag() { + let ev = sign(build_user_status("on call", Some(" ")).unwrap()); + assert_eq!(ev.content, "on call"); + assert!(tag_values(&ev, "emoji").is_empty()); + } + + #[test] + fn user_status_keeps_emoji_when_text_is_blank() { + let ev = sign(build_user_status("", Some("🎶")).unwrap()); + assert_eq!(ev.content, ""); + assert_eq!(tag_values(&ev, "emoji"), vec!["🎶"]); + } + + #[test] + fn user_status_clear_shape_is_empty_content_and_d_tag_only() { + let ev = sign(build_user_status("", None).unwrap()); + assert_eq!(ev.kind.as_u16(), 30315); + assert_eq!(ev.content, ""); + assert_eq!(tag_values(&ev, "d"), vec!["general"]); + assert_eq!(ev.tags.len(), 1); + } + + #[test] + fn user_status_rejects_oversize_text() { + let err = build_user_status(&"x".repeat(64 * 1024 + 1), None).unwrap_err(); + assert!(matches!(err, SdkError::ContentTooLarge { .. })); + } + // ── build_git_pull_request / build_git_pr_update ────────────────────────── fn pr_repo() -> GitRepoCoord { diff --git a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs new file mode 100644 index 00000000000..c37be8ca404 --- /dev/null +++ b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs @@ -0,0 +1,377 @@ +//! Regression test for the NIP-43 relay-admin durable-ban bypass +//! (BUZZ-SEC-007 class, reported 2026-07-27). +//! +//! `ingest_event` exempts relay-admin kinds 9030-9033 from its durable +//! write-path restriction gate so a *timed out* admin keeps its administrative +//! capability. That exemption was ban-blind, so a **banned** admin could still +//! add/remove relay members and change the workspace icon via signed NIP-98 +//! `POST /events`. The ban is now enforced inside +//! `relay_admin::handle_relay_admin_event`; this test pins both halves of that +//! contract — bans refused, timeouts still admitted. +//! +//! Requires a running relay and its Postgres. Ignored by default: +//! REPRO_RELAY_HTTP=http://localhost:3999 REPRO_HOST=localhost:3999 \ +//! DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz_relay_admin_regression \ +//! cargo test -p buzz-test-client --test regression_relay_admin_ban_gate \ +//! -- --ignored --nocapture + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +fn http_base() -> String { + std::env::var("REPRO_RELAY_HTTP").unwrap_or_else(|_| "http://localhost:3999".into()) +} +fn host() -> String { + std::env::var("REPRO_HOST").unwrap_or_else(|_| "localhost:3999".into()) +} +fn db_url() -> String { + std::env::var("DATABASE_URL").expect("DATABASE_URL required") +} + +fn sha256_hex(b: &[u8]) -> String { + hex::encode(Sha256::digest(b)) +} + +fn nip98(keys: &Keys, url: &str, body: &str) -> String { + let ev = EventBuilder::new(Kind::Custom(27_235), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &sha256_hex(body.as_bytes())]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + format!( + "Nostr {}", + BASE64.encode(serde_json::to_string(&ev).unwrap()) + ) +} + +async fn post_event(keys: &Keys, event: &nostr::Event) -> (u16, String) { + let body = serde_json::to_string(event).unwrap(); + let signed_url = format!("http://{}/events", host()); + let r = reqwest::Client::new() + .post(format!("{}/events", http_base())) + .header("Host", host()) + .header("Content-Type", "application/json") + .header("Authorization", nip98(keys, &signed_url, &body)) + .body(body) + .send() + .await + .expect("POST /events"); + let status = r.status().as_u16(); + (status, r.text().await.unwrap_or_default()) +} + +fn signed(keys: &Keys, kind: u16, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(kind), "") + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +async fn pool() -> sqlx::Pool { + sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&db_url()) + .await + .expect("connect Postgres") +} + +async fn community_id(p: &sqlx::Pool) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO communities (id, host) VALUES ($1, $2) ON CONFLICT (lower(host)) DO NOTHING", + ) + .bind(id) + .bind(host()) + .execute(p) + .await + .unwrap(); + sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)") + .bind(host()) + .fetch_one(p) + .await + .unwrap() +} + +async fn seed(p: &sqlx::Pool, cid: Uuid, keys: &Keys, role: &str) { + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(cid) + .bind(keys.public_key().to_bytes().to_vec()) + .execute(p) + .await + .ok(); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1,$2,$3,NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE SET role = $3, updated_at = now()", + ) + .bind(cid) + .bind(keys.public_key().to_hex()) + .bind(role) + .execute(p) + .await + .unwrap(); +} + +/// Post-fix regression bar. +/// +/// Asserts the full contract rather than just "the exploit stopped": +/// - banned admin: 403 + exact `blocked:` prefix on 9030/9031/9033, and a +/// banned *owner* likewise on 9032 (owner-only kind), covering all four +/// exempt kinds, +/// - no roster, role, or icon mutation from any of those attempts, +/// - a *timed-out* admin still reaches relay-admin authorization (the ingest +/// exemption's whole purpose — the fix must not silently widen to timeouts), +/// - an unrestricted admin's behaviour is unchanged, mutation included. +#[tokio::test] +#[ignore] +async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { + let p = pool().await; + let cid = community_id(&p).await; + + let owner = Keys::generate(); + let banned_owner = Keys::generate(); + let banned_admin = Keys::generate(); + let timed_out_admin = Keys::generate(); + let good_admin = Keys::generate(); + let victim = Keys::generate(); + let victim2 = Keys::generate(); + let victim3 = Keys::generate(); + let role_target = Keys::generate(); + // Retained (not generated inline) so the 9030 attempt can be checked for + // absence afterward — a planted member is the mutation that attempt buys. + let planted = Keys::generate(); + for (k, r) in [ + (&owner, "owner"), + (&banned_owner, "owner"), + (&banned_admin, "admin"), + (&timed_out_admin, "admin"), + (&good_admin, "admin"), + (&victim, "member"), + (&victim2, "member"), + (&victim3, "member"), + (&role_target, "member"), + ] { + seed(&p, cid, k, r).await; + } + + // Owner bans one admin and times out another, through the real 9040/9042 + // command path. + let (s, _) = post_event( + &owner, + &signed( + &owner, + 9040, + vec![Tag::parse(["p", &banned_admin.public_key().to_hex()]).unwrap()], + ), + ) + .await; + assert_eq!(s, 200, "ban must land"); + let expiry = (chrono_now() + 3600).to_string(); + let (s, b) = post_event( + &owner, + &signed( + &owner, + 9042, + vec![ + Tag::parse(["p", &timed_out_admin.public_key().to_hex()]).unwrap(), + Tag::parse(["expiration", &expiry]).unwrap(), + ], + ), + ) + .await; + assert_eq!(s, 200, "timeout must land: {b}"); + + // 9032 is owner-only, so its banned case needs a banned *owner*. Whether + // one owner may 9040 another is a moderation-policy question independent of + // this fix, so the ban row is seeded directly to keep the test pinned to + // the admission gate. + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1,$2,true,$3) \ + ON CONFLICT (community_id, pubkey) DO UPDATE SET banned = true", + ) + .bind(cid) + .bind(banned_owner.public_key().to_bytes().to_vec()) + .bind(owner.public_key().to_bytes().to_vec()) + .execute(&p) + .await + .unwrap(); + + // ── Banned actors: every relay-admin kind must be 403 + `blocked:`. ── + for (actor, kind, tags, label) in [ + ( + &banned_admin, + 9031u16, + vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()], + "9031 remove", + ), + ( + &banned_admin, + 9030u16, + vec![ + Tag::parse(["p", &planted.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "member"]).unwrap(), + ], + "9030 add", + ), + ( + &banned_admin, + 9033u16, + vec![Tag::parse(["icon", "https://evil.example/pwned.png"]).unwrap()], + "9033 icon", + ), + ( + &banned_owner, + 9032u16, + vec![ + Tag::parse(["p", &role_target.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "admin"]).unwrap(), + ], + "9032 change role", + ), + ] { + let (st, body) = post_event(actor, &signed(actor, kind, tags)).await; + println!("[banned] {label} -> {st} {body}"); + assert_eq!( + st, 403, + "{label}: banned actor must get 403, got {st} {body}" + ); + let msg: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + let text = msg.get("error").and_then(|v| v.as_str()).unwrap_or(&body); + assert_eq!( + text, "blocked: you are banned from this community", + "{label}: must carry the exact `blocked:` wire contract" + ); + } + + // No mutation from any banned attempt. + let role_of = |k: &Keys| { + let hex = k.public_key().to_hex(); + let p = p.clone(); + async move { + sqlx::query_scalar::<_, String>( + "SELECT role FROM relay_members WHERE community_id=$1 AND pubkey=$2", + ) + .bind(cid) + .bind(hex) + .fetch_optional(&p) + .await + .unwrap() + } + }; + assert_eq!( + role_of(&victim).await.as_deref(), + Some("member"), + "9031: banned admin must not remove a member" + ); + assert_eq!( + role_of(&planted).await, + None, + "9030: banned admin must not plant a new member" + ); + assert_eq!( + role_of(&role_target).await.as_deref(), + Some("member"), + "9032: banned owner must not change a member's role" + ); + let icon: Option = sqlx::query_scalar("SELECT icon FROM communities WHERE id=$1") + .bind(cid) + .fetch_one(&p) + .await + .unwrap(); + assert!( + icon.is_none(), + "9033: banned admin must not change the workspace icon, got {icon:?}" + ); + + // ── Timed-out admin: still administers (ingest exemption preserved). ── + let (ts, tb) = post_event( + &timed_out_admin, + &signed( + &timed_out_admin, + 9031, + vec![Tag::parse(["p", &victim2.public_key().to_hex()]).unwrap()], + ), + ) + .await; + println!("[timed-out] 9031 remove -> {ts} {tb}"); + assert_eq!( + ts, 200, + "timed-out admin must still administer the roster: {tb}" + ); + assert_eq!( + role_of(&victim2).await, + None, + "timed-out admin's removal must take effect" + ); + + // Control: the same timed-out admin is still write-blocked for content. + let (cs, cb) = post_event( + &timed_out_admin, + &EventBuilder::new(Kind::Custom(9), "x") + .tags(vec![Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()]) + .sign_with_keys(&timed_out_admin) + .unwrap(), + ) + .await; + println!("[timed-out] control kind:9 -> {cs} {cb}"); + assert_ne!( + cs, 200, + "timed-out admin must still be write-blocked for content" + ); + + // ── Unrestricted admin: behaviour unchanged, mutation included. ── + let (gs, gb) = post_event( + &good_admin, + &signed( + &good_admin, + 9031, + vec![Tag::parse(["p", &victim3.public_key().to_hex()]).unwrap()], + ), + ) + .await; + println!("[clean] 9031 remove -> {gs} {gb}"); + assert_eq!(gs, 200, "unrestricted admin must be unaffected: {gb}"); + assert_eq!( + role_of(&victim3).await, + None, + "unrestricted admin's removal must actually take effect" + ); + + // ── Unchanged rejection contract: non-admin still gets `invalid:`/400. ── + let nobody = Keys::generate(); + seed(&p, cid, &nobody, "member").await; + let (ns, nb) = post_event( + &nobody, + &signed( + &nobody, + 9031, + vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()], + ), + ) + .await; + println!("[non-admin] 9031 -> {ns} {nb}"); + assert_eq!( + ns, 400, + "a plain member's 9031 must stay a 400 validation reject" + ); + assert!( + nb.contains("invalid: actor not authorized"), + "non-admin rejection must keep its `invalid:` prefix, got {nb}" + ); + + println!("\nALL INVARIANTS HELD"); +} + +fn chrono_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/crates/buzz-workflow/src/error.rs b/crates/buzz-workflow/src/error.rs index 34e3d5311fc..292f8dd027c 100644 --- a/crates/buzz-workflow/src/error.rs +++ b/crates/buzz-workflow/src/error.rs @@ -54,6 +54,12 @@ pub enum WorkflowError { #[error("database error: {0}")] Database(String), + /// The workflow's owner is not currently authorized to run it (removed + /// from the channel, insufficient role for the definition's actions, or + /// the authority lookup failed — all deny, fail-closed). + #[error("unauthorized: {0}")] + Unauthorized(String), + /// The action is defined but not yet implemented. #[error("action not implemented: {0}")] NotImplemented(String), diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 93581225ee8..7aaa3d17026 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -132,6 +132,44 @@ impl WorkflowEngine { self.workflow_cache.invalidate(&(community_id, channel_id)); } + /// Fail-closed pre-run authority gate (SEC-006). + /// + /// A workflow executes with its **owner's** standing authority long after + /// the definition was saved, so every run-creation door must recheck the + /// owner's *current* channel authority immediately before creating a run: + /// + /// - the owner must still be an active member of the workflow's channel; + /// - if the definition contains an exfiltration-capable action + /// (`call_webhook`), the owner must currently hold the `owner` or + /// `admin` role. + /// + /// Any lookup error denies (fail-closed): a removed owner must never keep + /// exfiltration authority because a membership read happened to fail. + pub async fn check_owner_authority( + &self, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + def: &WorkflowDef, + ) -> Result<(), WorkflowError> { + let role = self + .db + .get_member_role(community_id, channel_id, owner_pubkey) + .await + .map_err(|e| { + WorkflowError::Unauthorized(format!( + "owner authority lookup failed (fail-closed): {e}" + )) + })?; + if owner_authority_allows(role.as_deref(), def.requires_elevated_authority()) { + Ok(()) + } else { + Err(WorkflowError::Unauthorized( + "workflow owner lacks current channel authority".into(), + )) + } + } + /// Set the action sink. Called once after `AppState` construction. /// /// # Panics @@ -340,6 +378,23 @@ impl WorkflowEngine { continue; } + // SEC-006: recheck the owner's *current* channel authority + // immediately before run creation. The cached workflow list can be + // up to 10s stale, and disable-on-removal can race a concurrent + // event — this per-fire gate is the authoritative, fail-closed + // check that a removed (or under-privileged, for exfiltration + // definitions) owner cannot cause a run. + if let Err(e) = self + .check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def) + .await + { + tracing::warn!( + workflow_id = %workflow.id, + "Skipping workflow — owner authority check failed: {e}" + ); + continue; + } + let trigger_event_id_bytes = event.event.id.as_bytes().to_vec(); let run_id = match self .db @@ -537,6 +592,22 @@ impl WorkflowEngine { _ => continue, // Non-schedule triggers handled by on_event() }; + // SEC-006: recheck the owner's current channel authority + // BEFORE the durable claim. Placing the gate after the claim + // would let a revoked owner's workflow consume the + // at-most-once fire slot (claims are never re-fired), turning + // revocation into a denial-of-fire for a later re-enable. + if let Err(e) = self + .check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def) + .await + { + tracing::warn!( + workflow_id = %workflow.id, + "Cron tick: skipping workflow — owner authority check failed: {e}" + ); + continue; + } + // Durable at-most-once claim — the cross-pod fire boundary. // The loser receives `None` and skips BEFORE any run creation or // side effect. `community_id` is the workflow row's own @@ -951,6 +1022,25 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge } } +/// Pure authority decision for [`WorkflowEngine::check_owner_authority`]. +/// +/// `role` is the owner's *current* active role in the workflow's channel +/// (`None` = not an active member — removed, left, or never joined). +/// `needs_elevated` is true when the definition contains an +/// exfiltration-capable action (see `WorkflowDef::requires_elevated_authority`). +/// +/// Rules: +/// - not a member ⇒ deny, always; +/// - member ⇒ allowed for ordinary definitions; +/// - elevated definitions ⇒ only `owner` / `admin` roles. +fn owner_authority_allows(role: Option<&str>, needs_elevated: bool) -> bool { + match role { + None => false, + Some(r) if needs_elevated => matches!(r, "owner" | "admin"), + Some(_) => true, + } +} + /// Returns `true` if the trigger type matches the given event kind. fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool { use buzz_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF}; @@ -1561,4 +1651,244 @@ steps: // Should pick the LAST e tag (direct target), not the first (thread root) assert_eq!(ctx.message_id, direct_target_id.to_hex()); } + + // -- SEC-006: owner authority decision -------------------------------- + + #[test] + fn owner_authority_denies_non_members_always() { + assert!(!owner_authority_allows(None, false)); + assert!(!owner_authority_allows(None, true)); + } + + #[test] + fn owner_authority_allows_any_member_for_ordinary_definitions() { + assert!(owner_authority_allows(Some("member"), false)); + assert!(owner_authority_allows(Some("admin"), false)); + assert!(owner_authority_allows(Some("owner"), false)); + } + + #[test] + fn owner_authority_requires_elevated_role_for_exfiltration_definitions() { + assert!(!owner_authority_allows(Some("member"), true)); + assert!(owner_authority_allows(Some("admin"), true)); + assert!(owner_authority_allows(Some("owner"), true)); + } + + #[test] + fn requires_elevated_authority_detects_call_webhook() { + let (plain, _) = WorkflowEngine::parse_yaml(concat!( + "name: plain\n", + "trigger:\n on: message_posted\n", + "steps:\n - id: s1\n action: send_message\n text: hi\n", + )) + .expect("parse plain"); + assert!(!plain.requires_elevated_authority()); + + let (hook, _) = WorkflowEngine::parse_yaml(concat!( + "name: hook\n", + "trigger:\n on: message_posted\n", + "steps:\n - id: s1\n action: send_message\n text: hi\n", + " - id: s2\n action: call_webhook\n url: https://example.com/x\n", + )) + .expect("parse hook"); + assert!(hook.requires_elevated_authority()); + } + + // -- SEC-006: event-path regression (requires Postgres) ---------------- + + async fn setup_db() -> buzz_db::Db { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + buzz_db::Db::new(&buzz_db::DbConfig { + database_url, + ..Default::default() + }) + .await + .expect("connect test DB") + } + + /// Create a community, a channel owned by `creator`, and add `member` as a + /// plain member. Returns `(community, channel)`. + async fn setup_channel(db: &buzz_db::Db, creator: &[u8], member: &[u8]) -> (CommunityId, Uuid) { + let host = format!("sec006-{}.example", Uuid::new_v4().simple()); + let community = match db + .create_community_with_owner(&host, &hex::encode(creator)) + .await + .expect("create community") + { + buzz_db::CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("unexpected community create result: {other:?}"), + }; + db.ensure_user(community, creator) + .await + .expect("creator user"); + db.ensure_user(community, member) + .await + .expect("member user"); + let channel_id = Uuid::new_v4(); + db.create_channel_with_id( + community, + channel_id, + &format!("ch-{}", channel_id.simple()), + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + creator, + None, + ) + .await + .expect("create channel"); + db.add_member( + community, + channel_id, + member, + buzz_db::channel::MemberRole::Member, + Some(creator), + ) + .await + .expect("add member"); + (community, channel_id) + } + + fn message_event(channel_id: Uuid) -> buzz_core::StoredEvent { + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello") + .sign_with_keys(&keys) + .expect("sign"); + buzz_core::StoredEvent::new(event, Some(channel_id)) + } + + /// The event path must stop creating runs the moment the workflow's owner + /// loses channel membership — even while the workflow row is still + /// `enabled` (the disable-on-removal side effect is a separate, relay-side + /// write; this gate must hold on its own). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn on_event_denies_run_after_owner_removed() { + let db = setup_db().await; + let creator = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let member = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let (community, channel_id) = setup_channel(&db, &creator, &member).await; + + let def_json = serde_json::json!({ + "name": "sec006-event", + "trigger": {"on": "message_posted"}, + "steps": [{"id": "s1", "action": "send_message", "text": "hi"}], + "enabled": true, + }) + .to_string(); + let workflow_id = db + .create_workflow( + community, + Some(channel_id), + &member, + "sec006-event", + &def_json, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default())); + + // Owner is an active member: the event fires the workflow. + engine + .on_event(community, &message_event(channel_id)) + .await + .expect("on_event while member"); + let runs = db + .list_workflow_runs(community, workflow_id, 10) + .await + .expect("list runs"); + assert_eq!(runs.len(), 1, "member owner's workflow must fire"); + + // Remove the owner (actor = channel creator, an owner-role member). + db.remove_member(community, channel_id, &member, &creator) + .await + .expect("remove member"); + + // Workflow row is still enabled — only the authority gate stands. + engine + .on_event(community, &message_event(channel_id)) + .await + .expect("on_event after removal"); + let runs = db + .list_workflow_runs(community, workflow_id, 10) + .await + .expect("list runs after removal"); + assert_eq!( + runs.len(), + 1, + "no new run may be created after the owner lost membership" + ); + } + + /// Exfiltration-capable definitions (call_webhook) require the owner to + /// currently hold an elevated role — a plain member's workflow must not + /// fire even though the owner is still an active channel member. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn on_event_denies_webhook_definition_for_plain_member_owner() { + let db = setup_db().await; + let creator = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let member = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let (community, channel_id) = setup_channel(&db, &creator, &member).await; + + let def_json = serde_json::json!({ + "name": "sec006-hook", + "trigger": {"on": "message_posted"}, + "steps": [{"id": "s1", "action": "call_webhook", "url": "https://example.com/x"}], + "enabled": true, + }) + .to_string(); + + // Same definition, two owners: plain member vs channel owner. + let wf_member = db + .create_workflow( + community, + Some(channel_id), + &member, + "hook-member", + &def_json, + &[0u8; 32], + ) + .await + .expect("create member workflow"); + let wf_owner = db + .create_workflow( + community, + Some(channel_id), + &creator, + "hook-owner", + &def_json, + &[1u8; 32], + ) + .await + .expect("create owner workflow"); + + let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default())); + engine + .on_event(community, &message_event(channel_id)) + .await + .expect("on_event"); + + let member_runs = db + .list_workflow_runs(community, wf_member, 10) + .await + .expect("member runs"); + assert!( + member_runs.is_empty(), + "plain member's call_webhook workflow must not fire" + ); + let owner_runs = db + .list_workflow_runs(community, wf_owner, 10) + .await + .expect("owner runs"); + assert_eq!( + owner_runs.len(), + 1, + "channel owner's call_webhook workflow fires" + ); + } } diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 34e8bb19603..9bc79aa48b3 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -147,6 +147,19 @@ pub enum ActionDef { } impl WorkflowDef { + /// True when any step performs an action that can exfiltrate channel data + /// to an arbitrary external destination (`call_webhook`). + /// + /// Definitions with such steps require elevated (owner/admin) channel + /// authority both to save and to run — plain membership is not enough, + /// because a workflow forwards channel content with the *owner's* standing + /// authority long after the save (SEC-006). + pub fn requires_elevated_authority(&self) -> bool { + self.steps + .iter() + .any(|s| matches!(s.action, ActionDef::CallWebhook { .. })) + } + /// Validate the workflow definition. Returns `Err` with a descriptive message /// if any invariant is violated. pub fn validate(&self) -> Result<(), WorkflowError> { diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 956e0857498..9309074895b 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.6 +version: 0.1.7 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Optional READ_DATABASE_URL env (secretKeyRef) enabling relay read-replica routing; absent key preserves prior behavior. + description: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 4cf4b22b245..a7c4bcf63b2 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -12,7 +12,7 @@ This chart has two operating profiles selected by values: ## Quickstart (eval only) ```sh -helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.0 \ +helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \ --create-namespace --namespace buzz \ --set quickstart=true \ --set postgresql.enabled=true \ @@ -52,6 +52,57 @@ See: The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`). +## Relay Pod extensions + +The chart exposes narrow extension points for init containers, volumes, relay +volume mounts, and image command/argument overrides. `extraManifests` creates +independent Kubernetes resources but cannot modify the chart-managed relay +Deployment. These extension values insert fields into that Deployment, avoiding +duplication of its environment, probes, security context, secrets, and +chart-owned volumes. + +For example, an init container can copy a wrapper binary into a shared volume +and make that wrapper the relay entrypoint: + +```yaml +extraInitContainers: + - name: install-wrapper + image: example.com/wrapper-init:v1 + args: [/opt/wrapper/wrapper] + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + resources: + requests: + cpu: 10m + memory: 16Mi + volumeMounts: + - name: wrapper + mountPath: /opt/wrapper + +extraVolumes: + - name: wrapper + emptyDir: {} + +relay: + command: [/opt/wrapper/wrapper] + args: [/usr/local/bin/buzz-relay] + extraVolumeMounts: + - name: wrapper + mountPath: /opt/wrapper +``` + +These values are raw Kubernetes fragments rendered with `toYaml`, not `tpl`. +The chart does not validate cross-field relationships: extension names must not +collide with chart-owned containers or volumes, mounts must reference existing +volumes, and each init container must define an appropriate security context +and resources. Empty `relay.command` and `relay.args` arrays preserve the image +defaults; non-empty values override its entrypoint and arguments respectively. + ## Device pairing relay The chart can run Buzz's stateless pairing WebSocket relay as an independent diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt index d65c1f837b1..b409f4d9424 100644 --- a/deploy/charts/buzz/templates/NOTES.txt +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -64,7 +64,8 @@ {{- end }} {{- if not .Values.relay.requireMediaGetAuth }} ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated. - Keep this false only during staged client rollout. + Anyone who learns a media URL/hash can fetch private attachments. Only + use for local development or fully public communities. {{- end }} {{- if not .Values.migrate.autoMigrate }} ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index f8d67de31de..bf2df4c2c86 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -55,13 +55,14 @@ spec: topologySpreadConstraints: {{- toYaml . | nindent 8 }} {{- end }} + {{- if or .Values.minio.enabled .Values.extraInitContainers }} + initContainers: {{- if .Values.minio.enabled }} # Quickstart only: the bundled MinIO bucket is created by a concurrent # init Job (templates/quickstart-minio-init.yaml). The relay's A3 S3 # conformance probe is startup-fatal, so without this gate the relay Pods # CrashLoopBackOff (with growing backoff) until the bucket appears. Block # relay start until the bucket exists — deterministic, no crash-loops. - initContainers: - name: wait-for-bucket image: {{ .Values.minio.mcImage | quote }} securityContext: @@ -90,12 +91,24 @@ spec: done echo "bucket {{ .Values.s3.bucket }} present" {{- end }} + {{- with .Values.extraInitContainers }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} containers: - name: relay image: {{ include "buzz.image" . }} imagePullPolicy: {{ .Values.image.pullPolicy }} securityContext: {{- toYaml .Values.relay.containerSecurityContext | nindent 12 }} + {{- with .Values.relay.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.relay.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} ports: - { name: app, containerPort: 3000, protocol: TCP } - { name: health, containerPort: {{ .Values.service.healthPort }}, protocol: TCP } @@ -225,6 +238,9 @@ spec: volumeMounts: - { name: git-repos, mountPath: {{ .Values.persistence.git.mountPath | quote }} } - { name: git-pack-cache, mountPath: {{ .Values.git.packCachePath | quote }} } + {{- with .Values.relay.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} volumes: - name: git-repos @@ -238,3 +254,6 @@ spec: - name: git-pack-cache emptyDir: sizeLimit: {{ .Values.git.packCacheVolumeSize | quote }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 6313fd59580..3e044f5d7c1 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -36,6 +36,34 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml + # Security default: media GET/HEAD reads must be auth-gated out of the + # box. A private attachment must never be publicly readable by URL/hash + # in an unmodified render. If this assertion fails, someone flipped the + # default — treat that as a security regression, not a config tweak. + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_REQUIRE_MEDIA_GET_AUTH + value: "true" + template: templates/deployment.yaml + + - it: lets an explicit value opt out of media read auth for dev/public deployments + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + relay.requireMediaGetAuth: false + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_REQUIRE_MEDIA_GET_AUTH + value: "false" + template: templates/deployment.yaml - it: lets an explicit value disable huddle audio in a single-replica render set: @@ -137,3 +165,118 @@ tests: - hasDocuments: count: 0 template: templates/pvc-git.yaml + + - it: preserves image defaults when Pod extensions are empty + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + asserts: + - notExists: + path: spec.template.spec.initContainers + template: templates/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].command + template: templates/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].args + template: templates/deployment.yaml + + - it: appends generic Pod extensions and overrides the relay command + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + relay.command: + - /opt/wrapper/wrapper + relay.args: + - /usr/local/bin/buzz-relay + relay.extraVolumeMounts: + - name: wrapper + mountPath: /opt/wrapper + extraInitContainers: + - name: install-wrapper + image: example.com/wrapper-init:v1 + args: + - /opt/wrapper/wrapper + env: + - name: LITERAL_TEMPLATE + value: '{{ .Release.Name }}' + securityContext: + runAsNonRoot: true + resources: + requests: + cpu: 10m + memory: 16Mi + volumeMounts: + - name: wrapper + mountPath: /opt/wrapper + extraVolumes: + - name: wrapper + emptyDir: {} + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: install-wrapper + template: templates/deployment.yaml + - equal: + path: spec.template.spec.initContainers[0].securityContext.runAsNonRoot + value: true + template: templates/deployment.yaml + # Extension fragments are deliberately rendered with toYaml, not tpl. + - equal: + path: spec.template.spec.initContainers[0].env[0].value + value: '{{ .Release.Name }}' + template: templates/deployment.yaml + - equal: + path: spec.template.spec.containers[0].command + value: + - /opt/wrapper/wrapper + template: templates/deployment.yaml + - equal: + path: spec.template.spec.containers[0].args + value: + - /usr/local/bin/buzz-relay + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: wrapper + mountPath: /opt/wrapper + template: templates/deployment.yaml + - contains: + path: spec.template.spec.volumes + content: + name: wrapper + emptyDir: {} + template: templates/deployment.yaml + + - it: appends generic init containers after the bundled MinIO readiness gate + release: + name: rel + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + postgresql.enabled: true + redis.enabled: true + minio.enabled: true + extraInitContainers: + - name: install-wrapper + image: example.com/wrapper-init:v1 + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: wait-for-bucket + template: templates/deployment.yaml + - equal: + path: spec.template.spec.initContainers[1].name + value: install-wrapper + template: templates/deployment.yaml diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 203fd9b69b7..53bb29bb608 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -72,9 +72,34 @@ "type": "array", "items": { "type": "string" } }, - "ephemeralTtlOverride": { "type": "integer", "minimum": 0 } + "ephemeralTtlOverride": { "type": "integer", "minimum": 0 }, + "command": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional relay container entrypoint override. Empty preserves the image default." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional relay container arguments override. Empty preserves the image default." + }, + "extraVolumeMounts": { + "type": "array", + "items": { "type": "object" }, + "description": "Raw Kubernetes volumeMount fragments appended to the relay container." + } } }, + "extraInitContainers": { + "type": "array", + "items": { "type": "object" }, + "description": "Raw Kubernetes init-container fragments appended to the relay Pod." + }, + "extraVolumes": { + "type": "array", + "items": { "type": "object" }, + "description": "Raw Kubernetes volume fragments appended to the relay Pod." + }, "service": { "type": "object", "additionalProperties": true, diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 180afc67462..8ac5086e275 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -107,10 +107,12 @@ relay: sendBuffer: 1000 requireAuthToken: true requireRelayMembership: true - # Staged rollout gate for authenticated media reads. When true, relay - # GET/HEAD /media/* requires Blossom kind 24242 t=get plus relay membership. - # Keep false until deployed desktop/mobile/CLI clients attach read auth. - requireMediaGetAuth: false + # Authenticated media reads: relay GET/HEAD /media/* requires Blossom + # kind 24242 t=get plus relay membership. Enabled by default so private + # attachments are never publicly readable by URL/hash. Only set false for + # local development or fully public communities — desktop, mobile, and CLI + # clients all attach read auth. + requireMediaGetAuth: true allowNipOaAuth: true pubkeyAllowlist: false corsOrigins: [] @@ -183,9 +185,24 @@ relay: readOnlyRootFilesystem: false # git writes need a writable repo path terminationGracePeriodSeconds: 60 + # Optional image entrypoint/arguments overrides. Empty arrays preserve the + # relay image's defaults. Consumers own compatibility with the selected image. + command: [] + args: [] + # Appended to the chart-owned relay mounts. Names must match extraVolumes (or + # another volume supplied by the platform) and must not collide with built-ins. + extraVolumeMounts: [] + extraEnv: [] extraEnvFrom: [] +# ── Pod extensions ────────────────────────────────────────────────────────── +# Raw Kubernetes fragments appended to the relay Pod. They are rendered with +# toYaml, not tpl. Init containers must define their own securityContext and +# resources; names must not collide with chart-owned containers or volumes. +extraInitContainers: [] +extraVolumes: [] + # ── Device pairing relay ───────────────────────────────────────────────────── # Optional, stateless NIP-AB relay. When enabled, the main relay advertises # pairingRelay.url in NIP-11 and Buzz clients use it instead of the legacy diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index cebe879da0d..db1f2eb2521 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -11,6 +11,10 @@ RELAY_URL=wss://buzz.example.com BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com BUZZ_CORS_ORIGINS=https://buzz.example.com +# Optional URL path prefix, for gateways that route by path rather than by +# hostname (the relay is normally served at the root, so leave this unset). +# When set, RELAY_URL and BUZZ_MEDIA_BASE_URL must carry the same prefix. +# BUZZ_BASE_PATH=/relay # Production defaults. Closed relay mode requires RELAY_OWNER_PUBKEY and a stable relay key. BUZZ_REQUIRE_AUTH_TOKEN=true @@ -30,7 +34,6 @@ POSTGRES_DB=buzz POSTGRES_USER=buzz POSTGRES_PASSWORD=CHANGE_ME_RANDOM_PASSWORD REDIS_PASSWORD=CHANGE_ME_RANDOM_PASSWORD -TYPESENSE_API_KEY=CHANGE_ME_RANDOM_API_KEY BUZZ_S3_ACCESS_KEY=CHANGE_ME_RANDOM_ACCESS_KEY BUZZ_S3_SECRET_KEY=CHANGE_ME_RANDOM_SECRET_KEY BUZZ_S3_BUCKET=buzz-media @@ -45,7 +48,6 @@ CADDY_HTTPS_PORT=443 # Dev override ports. Only used with compose.dev.yml. POSTGRES_PORT=5432 REDIS_PORT=6379 -TYPESENSE_PORT=8108 MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 ADMINER_PORT=8082 diff --git a/desktop/package.json b/desktop/package.json index 8c5795e359a..7943b949b94 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.4.26", + "version": "0.5.1", "type": "module", "scripts": { "dev": "vite", @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes && 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\"", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7e316dd5432..459fa757432 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ "**/video-attachment.spec.ts", "**/spoiler.spec.ts", "**/composer-link-shortcut.spec.ts", + "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", "**/team-mentions.spec.ts", @@ -102,6 +103,8 @@ export default defineConfig({ "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", + "**/drafts-all-fix-screenshots.spec.ts", + "**/inbox-refactor-screenshots.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", @@ -122,6 +125,8 @@ export default defineConfig({ "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", "**/harness-management.spec.ts", + "**/harness-catalog-screenshots.spec.ts", + "**/inline-custom-harness.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/public/harness-logos/CREDITS.md b/desktop/public/harness-logos/CREDITS.md index 654a6b74284..de4e003c3f2 100644 --- a/desktop/public/harness-logos/CREDITS.md +++ b/desktop/public/harness-logos/CREDITS.md @@ -15,8 +15,25 @@ license permits redistribution. | `kimi.png` | [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) | `4a550effdfcb29a25a5d325bf935296cc50cd417` | Apache-2.0; NOTICE: Kimi Code CLI © 2025 Moonshot AI | `web/public/logo.png` | None | | `grok.svg` | [SpaceXAI brand guidelines](https://x.ai/legal/brand-guidelines) | Retrieved 2026-07-25 | xAI Brand Guidelines: marks may be used to accurately refer to xAI or its services; logos must be used exactly as provided | `SpaceXAI_Grok_Assets.zip` → `Grok_Logomark_Dark.svg` | None | +## Inline SVG marks (`RUNTIME_MARKS`) + +Monochrome marks inlined as `currentColor` paths in +`desktop/src/features/onboarding/ui/HarnessMarks.tsx` (no files under +`public/`), so they adapt to dark/light themes without bitmap filters. + +| Mark | Upstream | Version/Commit | License | Source path | Modifications | +|---|---|---|---|---|---| +| Goose | [block/goose](https://github.com/block/goose) | `305849b71709b95b86ed9f11bd3bc939899c0aab` | Apache-2.0 © Block, Inc. | `documentation/static/img/goose.svg` | `fill="#101010"` → `currentColor`; dropped the redundant clipPath wrapper | +| Cursor | [simple-icons](https://github.com/simple-icons/simple-icons) | `16.27.1` (slug `cursor`) | CC0-1.0 (path data); nominative use of the Cursor mark to identify Cursor's harness | `icons/cursor.svg` | `fill` → `currentColor` | + +Codex deliberately has **no** bundled mark: the OpenAI blossom was removed +from simple-icons in v16 at the vendor's request, so we do not ship it — +Codex renders `RuntimeIcon`'s neutral terminal-glyph fallback instead. + `amp.png` and `opencode.svg` predate this file; their provenance was not -recorded when they were added. Cursor intentionally uses the generic terminal -fallback: Cursor's official brand page offers downloadable assets, but neither -that page nor its Terms of Service grants third parties permission to -redistribute them. The previous unproven `cursor.png` was removed. +recorded when they were added. Cursor previously used the generic terminal +fallback because Cursor's own brand page does not grant redistribution; the +CC0-licensed simple-icons path (above) resolves that, mirroring the grok +nominative-use precedent. The previous unproven `cursor.png` was removed, as +were the unproven `chatgpt.png` and `goose.png` builtin-runtime bitmaps +(replaced by the inline marks above). diff --git a/desktop/public/runtime-icons/codex.png b/desktop/public/runtime-icons/codex.png deleted file mode 100644 index fbdd19c060e..00000000000 Binary files a/desktop/public/runtime-icons/codex.png and /dev/null differ diff --git a/desktop/public/runtime-icons/goose.svg b/desktop/public/runtime-icons/goose.svg deleted file mode 100644 index c5ed5e00a8f..00000000000 --- a/desktop/public/runtime-icons/goose.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 55bf334f284..326587b87f8 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -46,636 +46,8 @@ const rules = [ }, ]; -// TEMP — these files exceed the 1000-line limit and are queued to be split. -// Do not add to this list; split the file instead. Remove each entry as its -// file is broken up. Tracked as a follow-up. -const overrides = new Map([ - // Native Builderlab auth/community commands add a small registration surface - // to the existing Tauri composition root. The implementation lives in - // builderlab.rs; this narrowly ratchets the command wiring while lib.rs is - // queued for a broader composition-root split. Bumped for the - // archive/unarchive/transfer community-management commands (web parity). - ["src-tauri/src/lib.rs", 1013], - // persona-events rebase: build_deploy_payload threads `state` for the - // read-time relay-URL workspace fallback while keeping the create-time env - // pin (the credential-leak guard). Load-bearing feature growth from the - // rebase, queued to split with the rest of this list. - // persona-refresh-on-spawn: re-snapshot + retain_managed_agent_pending call - // in start_local_agent_with_preflight adds ~23 lines. Queued to split. - // rebase onto main (2026-06-25): main's agents.rs grew by ~17 lines since - // config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config - // commands add ~40 lines. Queued to split. - // branch cut; override bumped to cover the merged total. Queued to split. - // persona-blank-fallback: persona_snapshot_with_agent_config_fallback call - // sites add ~4 lines (extra fallback params + inline comments). build_deploy_payload - // fix (blank-persona provider/model fallback) adds ~6 lines. Bug fix. - // archive/mod_tests.rs carries the full test module for archive/mod.rs: - // unit tests + 4 real-relay integration tests (ignored, live-relay only). - // Production logic in mod.rs is now ~527 lines (under 1000). mod_tests.rs - // is test-only content; the override covers the test growth accumulated - // across the local-archive + agent-metric-archive PR series. store_tests.rs - // (~731 lines) is under 1000 so needs no override. - ["src-tauri/src/archive/mod_tests.rs", 1208], - // unified-agent-model 1A.1: profile reconcile split to agents_profile.rs, - // ratcheting 1443 -> 1295. Queued to split further in the A2 fold. - // global-agent-config: resolve_deploy_model_provider + visibility exports - // add ~40 lines on top of the 1A.1 ratchet. Queued to split. - // +29 (1340 -> 1369, main): agent-config-resolver — start_local_agent_with_preflight - // uses resolve_effective_relay_mesh_model_id at both preflight call sites; - // preview_prospective_persona_snapshot helper extracted; orphan guard threaded - // through restore path; start_local_agent_pairs_with_preflight resolver - // preflight. Load-bearing feature changes; queued to split. - // +47 (#2773): review fix — load_global_agent_config hoisted out of - // build_managed_agent_summary into callers, dangling-harness summaries render - // the deleted id, and spawn errors surface as sentences (tests included). - // +1: merge of the two deltas above (actual post-merge count). - ["src-tauri/src/commands/agents.rs", 1418], - // agent-lifecycle-fixes: cascade-delete in delete_persona restructured into - // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for - // retry-safety. Load-bearing reviewer-required change; queued to split. - // Consolidation removed the legacy persona-card import/export codecs. - ["src-tauri/src/commands/personas/mod.rs", 984], - // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS - // const + build_thread_replies_filter helper, mirroring the channel sibling so - // the two p-gate filters can't drift) plus two guard unit tests. The file was - // already at 995; this load-bearing correctness fix crossed 1000. Not generic - // debt growth. Approved override; queued to split with the rest of this list. - ["src-tauri/src/commands/messages.rs", 1082], - // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned - // outside NEST_DIRS (it may be a symlink), so it needs its own create + - // chmod-only-when-real-dir handling plus integration test coverage. The - // self-contained repos_dir functions and their unit tests live in repos.rs; - // this is the seam that must stay in nest.rs. Approved override; still queued - // to split with the rest of this list. - // dev-nest namespace: OnceLock> + init_nest_dir + constants - // added to plumb the dev/prod discriminator. Load-bearing for the D2 nest fix. - // dev-build CLI symlink: cli_link_name helper + is_dev param on - // ensure_cli_symlink + prod/dev test variants add ~68 lines. Load-bearing; - // queued to split with the rest of this list. - // +4 lines: adopt shared create_symlink wrapper (behavior-preserving refactor - // for multi-line rustfmt expansion of the skills symlink call site). - // unified-agent-model 1A.1: inline test module moved to nest/tests.rs, - // ratcheting 1575 -> 679 (under the 1000 default; entry kept as a ratchet). - // observer-archive dev-default: path_is_dev_nest + nest_is_dev getters - // (+25 lines) so observer_archive_default_enabled() keys off the dev nest. - // Load-bearing; spends banked ratchet headroom, still well under 1000. - ["src-tauri/src/managed_agents/nest.rs", 704], - // keyring-dev-isolation: agent key migration added copy_agent_keys_between_stores - // and load_readonly support; file grew past 1000 default. Queued to split. - // +7 for try_delete_agent_key result-returning seam (snapshot-import rollback). - // +48 (1335 -> 1383): agents-everywhere pair re-key — pair-scoped runtime - // receipts (write_agent_runtime_receipt atomic JSON + remove/read_all - // helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped - // runtime log path. Load-bearing crash-recovery surface; queued to split. - ["src-tauri/src/managed_agents/storage.rs", 1383], - // config-bridge setup-payload env-boundary fix adds readiness wiring in - // spawn_agent_child; load-bearing security fix, queued to split. - ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], - // config-bridge-aware requirements: goose_requirements + injection tests - // (4 new tests in goose_file_config_tests module) + test-determinism fixes - // for the 3 existing goose tests that previously read real disk config. - // New file in this PR; queued to split. - // +2 readiness integration tests for flat-DATABRICKS_HOST canonicalization fix. - // +1 cargo fmt whitespace reformat (readiness.rs closures inline after rebase). - // +2 unit tests for cli_login_requirements resolve_command integration (DMG PATH fix). - // Doctor-CTA: reworked cli_login_requirements to carry AcpAvailabilityStatus, - // skip login probe for not-installed/adapter-missing/cli-missing states, and - // added 4 unit tests covering each arm. Load-bearing discoverability fix. - // Updated existing codex_not_ready test to use make_cli_runtime stub. - // +4 lines: #1640 persona-env-vars-refresh rebase added availability-classification - // growth in the live-persona env merge path. Feature plumbing, not generic debt. - // Windows-CI portability: replaced POSIX true/false probes with current_exe() - // stand-in + present_binary_str()/static_commands() helpers (+29 lines). - // Tests now pass on windows-latest CI shard without POSIX shell utilities. - // databricks-v1-to-v2-migration: databricks-v2 hyphen-alias added to all - // host/credential match arms + 30+ readiness tests for provider aliases, - // missing-host, and DATABRICKS_MODEL fallback. Load-bearing correctness fix. - // #1613 augmented-PATH readiness probes grew the file +3 past the prior cap. - // +16: resolve_effective_agent_env + global-config readiness wiring (#1448). - // +1 rebase merge: GlobalAgentConfig import added alongside AcpAvailabilityStatus. - // +2 rebase onto #1667: behavioral quad fields in AgentDefinition/ManagedAgentRecord. - // +3 rebase onto main (#1568 + #1613): identity-import-keyring + augmented-PATH probes. - // +18: CliConfigInvalid requirement surface for config-parse probe classification — - // new Requirement variant + updated cli_login_requirements + 3 new probe-layer tests. - // Load-bearing UX fix (bad config → clear diagnostic, not "run codex login"). - // codex-acp-package-swap: AdapterOutdated version-probe in cli_login_requirements - // (+22 lines). Load-bearing — blocks login gate for deprecated 0.16.x adapter. - // code-reviewer fix-round: codex readiness gate tests — 2 new tests for - // outdated-adapter and garbage-version-output paths through the codex id gate - // (+140 lines: make_codex_runtime helper, PATH_MUTEX serializer, 2 test fns). - // Load-bearing test coverage; queued to split with the file generally. - // +1: pub(crate) mod cli_probe declaration for doctor auth probe access. - // +3: auth_probe_args: None + login_hint: None added to make_cli_runtime and - // make_codex_runtime stubs (new KnownAcpRuntime fields). - // Git Bash readiness is intentionally colocated with buzz-agent's other - // setup-mode requirements. The Windows-only requirement and serialization - // test add eight lines; split remains queued with the existing file debt. - // Windows Doctor install fix: cli_install_commands_windows field added to test stubs. - // team-instructions-first-class: ManagedAgentRecord fixture gains the new - // team_id field (+1 line). - ["src-tauri/src/managed_agents/readiness.rs", 1863], - // Windows PATH-correctness fix: 3 #[cfg(windows)] test functions covering - // .cmd shim rejection, .bat shim rejection, and .exe acceptance for - // configure_runtime_cli (fix #2397). Test-only growth; queued to split. - // +7 (main): this PR's resolver tests land on top of main's #2397 Windows - // shim tests, plus main's restart_eligible orphan-gate tests. - // +34: BYOH custom-harness sweep condition unit tests — 3 tests validating - // the OR-gate fix for custom-binary orphan cleanup. - // +26: BYOH pass-2 I3 — 2 collector-decision tests for receipt path - // ownership (valid_agent_runtime_receipt uses buzz_sweep_owns_process). - ["src-tauri/src/managed_agents/runtime/tests.rs", 1320], - // runtime.rs re-entered the list after the #1968 merge: main's - // definition-authoritative resolver comments grew it to 982, and this PR's - // typed harness-descriptor resolution in spawn_agent_child (+38) lands on - // top. Queued to shrink with the next runtime split pass (#2974 follow-up). - ["src-tauri/src/managed_agents/runtime.rs", 1020], - // applyWorkspace reposDir parameter plus the validateReposDir binding, - // threaded through Tauri invokes for configurable repos_dir, plus the - // harness-persona-sync `harnessOverride` create-input bit — load-bearing - // parameter plumbing, not generic debt growth. Approved override; still - // queued to split. Read-path lanes 1+2 add server-side fetch bindings - // (getThreadReplies + getChannelMessagesBefore) and paged people-search - // reachability — load-bearing reachability plumbing, not generic debt. - // #1418 read-path fix: +3 doc-only lines correcting the getThreadReplies - // contract (replies-only, root excluded — the query keys on root_event_id, - // which root rows lack). Documentation accuracy, not code growth. - // linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field. - // config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines. - // +26 lines from PRs landing on main between prior rebase and this rebase. - // baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. Queued to split. - // restart-badge: started the queued split — start/stopManagedAgent moved to - // tauriManagedAgents.ts; limit ratcheted down 1388 → 1380 to bank the headroom. - // identity-import-keyring: identity wrappers (RawIdentity, getIdentity, getNsec, - // importIdentity, persistCurrentIdentity) moved to tauriIdentity.ts; - // limit ratcheted down 1380 → 1360 to bank the headroom (absorbs main-side - // growth landed between the split and the rebase). - // mention-alias fix: profile wrappers (RawProfile/RawUserProfileSummary types, - // getProfile/updateProfile/getUserProfile/getUsersBatch/searchUsers) moved to - // tauriProfiles.ts; limit ratcheted down 1360 → 1241 to bank the headroom. - // baked-env fold-in: getBakedBuildEnv + BakedEnvEntry type adds ~28 lines. - // doctor-npm-eacces-preflight: hint field on RawInstallStepResult + mapper - // passthrough (+2 lines). - // doctor-install-reliability: node_required + auth_status + login_hint fields - // added to RawAcpRuntimeCatalogEntry + fromRawAcpRuntimeCatalogEntry mapper (+8). - // codex-install-auto-restart: restarted_count + failed_restart_count added to - // RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2). - // Git Bash Doctor discovery adds the raw Tauri response and its camelCase - // mapper. This is the existing API boundary; split remains queued. - // team-instructions-first-class: createManagedAgent Tauri bridge threads the - // new teamId input through to the backend (+1 line). - // +2 for model_source field in RawManagedAgent + fromRawManagedAgent mapping. - ["src/shared/api/tauri.ts", 1307], - // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). - // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). - // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ - // loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature. - // agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with - // failed_restart_count (+2 lines). Queued to split with the rest of this list. - // mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling. - // Git Bash prerequisite payload adds four fields to the shared Tauri API - // contract. This is the canonical type location; split remains queued. - // signout-wipe: resetFailed field added to Identity type (+6 lines). - // team-instructions-first-class: CreateManagedAgentInput.teamId (+2, incl. - // doc comment) and AgentTeam/CreateTeamInput/UpdateTeamInput.instructions - // (+3) — the new team-id spawn link and the runtime-layered instructions - // field. - // byoh-env-roundtrip: AcpRuntimeCatalogEntry.definitionEnv field + JSDoc - // (+12 lines) so the edit form can read back existing env vars on save. - // Load-bearing correctness fix. Queued to split. - // +2: AcpRuntimeCatalogEntry.requiresExternalCli field added by main - // (#2680) to indicate runtimes that need a separate CLI install. - // +6: ManagedAgent.runtime record-level pin + JSDoc so the harness delete - // confirmation can count referencing agents (review fix for #2773). - ["src/shared/api/types.ts", 1058], - // harness-persona-sync feature growth, queued to split in the resolver-unify - // refactor followup. discovery.rs is dominated by the new test module - // (the effective_agent_command / divergent / create-time override matrix); - // alias-preservation coverage extends that matrix so create-time persona - // agents keep an installed runtime alias when the primary command is absent. - // Load-bearing, not generic debt. - // config-bridge: schema-driven field extraction adds ~26 lines. Queued to split. - // config-parity: max_tokens_env_var + context_limit_env_var fields added to - // KnownAcpRuntime (2 fields × 4 runtimes + discovery tests = ~13 lines). - // Load-bearing — required for buzz-agent normalized config parity. - // same-runtime-pin: update_time_agent_command_override + its override / - // same-runtime / alias / sentinel / non-override / persona-less test matrix - // (~135 lines, mostly tests) so a deliberate Custom pin survives the update - // path instead of being dropped back to inherit. Load-bearing, not debt. - // unified-agent-model 1A.1: inline test module moved to discovery/tests.rs, - // ratcheting 1259 -> 802 (under the 1000 default; entry kept as a ratchet). - // agent-config-propagation: the agent_command_override decision family - // (divergent / create-time / update-time / apply) moved to - // discovery/overrides.rs; ratcheting 802 -> 685 to bank the headroom. - // codex-acp-package-swap: probe_codex_acp_major_version (+24 lines) + - // AdapterOutdated version-gate in discover_acp_runtimes (+22 lines). Both - // load-bearing — required to detect the deprecated 0.16.x adapter and - // prevent silent relay breakage after the spawn-contract change. - // codex-acp-package-swap follow-up: tempfile-based bounded stdout read - // (+18 lines), codex_adapter_availability/is_outdated helpers (+16 lines), - // cross-platform probe contract. All load-bearing — required for correct - // probe behaviour on Windows and descendant-process edge cases. - // doctor-install-reliability: refreshable login_shell_path cache, - // find_nvm_default_bin + parse_semver_tag helpers, auth probe cache + - // probe_auth_status/cached_auth_status, runtime_needs_npm, probe_args_for, - // PartialEntry struct, and updated discover_acp_runtimes with parallel auth - // probes. Load-bearing fresh-install reliability fixes. (+289 lines) - // doctor-install-reliability review fixes: LoginShellPath enum + double-checked - // locking, is_safe_nvm_tag security validation, classify_probe_output helper, - // auth_probe_args on KnownAcpRuntime (removes probe_args_for indirection), - // process-level timeout replacing inner-thread pattern. (+75 lines) - // codex-install-auto-restart review-fixes: availability_drift pure predicate - // + updated adapter_availability_cached() signature (Option return, cold=None) - // prevents false restart badge on newly restarted agents. Correctness fix; - // load-bearing — required by Thufir's IMPORTANT findings. (+15 lines) - // Windows Doctor install fix: cli_install_commands_windows field, impl block - // for cli_install_commands_for_os(), command_basenames() + .cmd/.bat resolution, - // Windows well-known dirs in common_binary_paths(), login_shell_candidates(), - // path_candidates_from_env_raw(). Load-bearing Windows platform support. - // +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None). - // resolve_git_bash made pub(crate) for Windows test access. - // +1: login_shell_candidates doc comment expanded for resolve_bash_path. - // Buzz-managed Node path helpers and resolution tests moved to - // managed_node_paths.rs and discovery/tests/managed_path_resolution.rs; - // ratcheting 1366 -> 1392 after adding the managed-path probes to discovery. - // +17: BYOH custom harness catalog merge phase-3 — append custom definitions - // from custom_harnesses_dir with PATH-probe availability; source tagging. - // +148: BYOH F2/F3 — PRESET_HARNESSES static data (6 presets), Phase 2.5 in - // discover_acp_runtimes_from (PATH-probe each preset, build catalog entries, - // populate loaded-harness registry), record/effective command resolution now - // checks loaded registry for preset/custom ids. Queued to split presets out. - // +3: BYOH F5 — seen_ids rejects preset/builtin collisions from custom files. - // +79: BYOH pass-2 C1 — 4 registry lifecycle tests (warm→spawn, delete→ - // dangling, immediate save+start, edit with rename); try_record_agent_command - // typed error for dangling ids wired into spawn; readiness/spawn_hash now - // include definition env floor. - // +7: BYOH pass-2 I2 env round-trip — definition_env field populated in - // custom catalog entries + 2 discriminating tests (custom env preserved, - // builtin env empty). Load-bearing edit round-trip fix. - // +16: BYOH scope addition — Hermes Agent + OpenClaw preset entries (two - // data-only PresetHarness structs; no new logic or test functions). - // +29: rebase over main (#2680) — discover_acp_runtime_phase1 extracted - // helper + discover_acp_runtime_availability; both load-bearing for - // post-install verification. Semantic composition with BYOH changes. - // +17: merge of main (#2767) — codex_adapter_is_outdated_with_path split out - // so Codex adapter planning takes an explicit PATH. Auto-merged cleanly; only - // the ceiling needed composing with the BYOH growth above. - // +13: review fix for #2773 — discovery publishes the registry by re-reading - // the harness dir under persist_mutex (publish_harness_registry_from_dir call - // + doc comment), closing the stale-snapshot clobber race. - // +35: review round 2 (#2773) — cfg(test) pre_publish_test_hook seam so the - // stale-publish regression is pinned through the REAL discover_acp_runtimes_from - // path (Wren's finding: the seam-only tests stayed green under a stale-publish - // mutant). Test-only code, zero release-build footprint. - // +55: #2773 follow-up — PresetHarness.underlying_cli (Amp's amp-acp wraps - // the amp CLI) + preset_catalog_entry helper: adapter presence alone keeps - // deciding Available (adapter-present/CLI-absent stays selectable, Wren's - // regression catch); underlying_cli is consulted only when the adapter is - // absent, so AdapterMissing replaces the misleading NotInstalled. Includes - // the deliberate-divergence doc comments; net after the inline preset - // entries.push block collapsed into the helper. - ["src-tauri/src/managed_agents/discovery.rs", 1835], - // BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm / - // delete_and_warm (persist-mutex serialization for concurrent-safe registry - // refresh, B-6). Also: id/collision/load/registry tests (from the file base) + - // B-4 real persistence tests (create, same-id edit, rename, backup cleanup) + - // B-3 env validation boundary tests (malformed key, reserved shape, NUL, - // size limit, ownership marker). Load-bearing correctness/security coverage; - // queued to extract helper module once the feature stabilizes. - // +153: review fix for #2773 — collision/dup filtering moved into - // load_custom_harnesses so warm + discovery inherit identical shadowing - // rules, publish_harness_registry_from_dir (mutex-scoped publish seam), and - // comma-in-args validation at validate_harness_definition, with tests. - // +34: review round 2 (#2773) — Dawn's mutation finding: the loader-boundary - // collision/dedup enforcement was untested (deleting it left the suite green). - // load_applies_id_collision_check now drives the real loader against a real - // shadowing file, plus a dedup twin; both verified to kill the mutants. - ["src-tauri/src/managed_agents/custom_harnesses.rs", 1232], - // rebase over codex-acp-package-swap: its version-probe tests union with the - // doctor-install-reliability nvm/login-shell/semver tests — each side alone - // stayed under the 1000 default; the union exceeds it. - // Windows Doctor install fix: command_basenames, cli_install_commands_for_os, - // and login_shell_candidates tests. Load-bearing platform-awareness coverage. - // +132: pass 2 — five cfg(windows) behavioral tests: command_basenames .cmd/.bat - // candidates, cli_install_commands_for_os PowerShell selection, login_shell_path - // None regression, .cmd shim resolution, no-git-bash error hint. - // +32: deterministic .cmd resolver + no-registry + install_shell_from tests. - // Managed-path resolution test split to discovery/tests/managed_path_resolution.rs. - // +227: BYOH pass-2 C1 — 4 registry lifecycle tests (warm→spawn, delete→dangling, - // immediate save+start, edit with rename) added to discovery/tests.rs. - // +64: BYOH pass-2 I2 env round-trip — 2 discriminating tests proving custom - // catalog entries carry definition_env and builtins do not. - // +90: review fix for #2773 — deterministic interleaving regressions for the - // discovery publish race (save-during-discovery survives publish; - // delete-during-discovery stays gone). - // +103: review round 2 (#2773) — production-path interleaving regressions: - // discovery_publish_path_survives_mid_flight_save / _drops_mid_flight_delete - // drive the real discover_acp_runtimes_from with a save/delete landed via the - // pre_publish_test_hook; verified to red under a stale-publish mutant. - // +18: flake fix — lock_path_mutex + registry_test_lock guards (with lock- - // order comments) on the four tests that drive discovery's global caches. - // +84: #2773 follow-up — preset_catalog_entry coverage (Amp-shaped adapter - // preset: AdapterMissing when CLI present, NotInstalled both-missing, - // Available both-present AND adapter-present/CLI-absent — the selectability - // regression guard), bound to an injectable resolver so the tests stay - // PATH-independent. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1871], - // identity-import-keyring: the identity resolution state machine's behavioral - // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, - // adoption / read-back-corruption / marker-failure arms, recovery-mode - // gating). Load-bearing regression coverage for silent identity rotation, - // not generic debt growth. Approved override; split if the matrix grows. - ["src-tauri/src/app_state_tests.rs", 1420], - // migration_tests.rs carries the harness-sync migration coverage plus the - // patch_json_records owner-only writeback regression test (SECURITY.md:90 - // crash-safe 0o600 fallback). Load-bearing security + feature coverage, not - // generic debt growth. Approved override; still queued to split. Event-sync - // (persona/team event reconcile) tests were split out to event_sync_tests.rs - // and the limit ratcheted 1410 → 1110. - // unified-agent-model 1A.1: materialize tests live with their module in - // migration/materialize.rs; ratchet held at 1110. - ["src-tauri/src/migration_tests.rs", 1110], - ["src-tauri/src/nostr_convert.rs", 1126], - // degraded-network resilience: relay.rs grew past 1000 with the addition of - // relay_error_message hint-capping (oversized-hint test via loopback TCP) and - // the relay_admission freshness-verification test. The loopback mock was - // hardened (std::net + request-read-before-write) adding ~10 lines. - // Queued to split test helpers to relay/tests.rs. - // +30 (1047 -> 1077): agents-everywhere pair re-key — query_relay_at_with_keys - // (NIP-98 signed /query with explicit agent keys + optional x-auth-tag) for - // bounded-auth agent relay-membership discovery. Load-bearing; queued to - // split alongside the test-helper split. - ["src-tauri/src/relay.rs", 1077], - // degraded-network resilience: visibleChannelId field + getter/setter, NOTICE - // handler for relay back-pressure, and rate-limit gate imports add ~74 lines - // of load-bearing degraded-network recovery code. Queued to split. - ["src/shared/api/relayClientSession.ts", 1096], - // Boot-time event sync (persona/team/agent event reconcile) was split out - // to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is - // the pre-identity data migrations; still queued to split further. - // unified-agent-model 1A.1: materialize_agent_runtimes split to - // migration/materialize.rs, ratcheting 1310 -> 1297. - // databricks-v1-to-v2-migration: reconcile_databricks_v1_to_v2 migration - // + inner fn with baked-env gate + 26 tests. Load-bearing correctness fix. - // am review fix: also clear stale V1 model field on provider rewrite + - // new model-clear test. Load-bearing chimera fix. - // keyring-dev-isolation: run_boot_migrations wires agent-key migration. - ["src-tauri/src/migration.rs", 1436], - // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop - // already here) for the single-toggle mark-read/unread menu item — a small - // overage from load-bearing per-message plumbing, not generic debt growth. - // Approved override; still queued to split with the rest of this list. - ["src/features/messages/ui/MessageThreadPanel.tsx", 1006], - // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge - // panel — a small overage from load-bearing UI plumbing, not generic debt - // growth. Approved override; still queued to split with the rest of this list. - // +135 for AgentInfoFocusedView/DiagnosticsFocusedView/ChannelsFocusedView - // props restored after 826d735fe removal (UserProfilePanel.tsx still needs them). - ["src/features/profile/ui/UserProfilePanelSections.tsx", 1140], - // +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action). - // +11 for editAgentFocus state + initialFocus prop threading (deep-link granularity). - ["src/features/profile/ui/UserProfilePanel.tsx", 1025], - // PersistBackend enum + marker-on-keyring-success plumbing and its three - // fail-closed regression tests (silent identity rotation on keyring outage). - // A small overage from load-bearing security plumbing on a file already at - // 893 lines, not generic debt growth. Approved override; still queued to split. - // cross-process keychain race fix (D3): interprocess lock + BlobLockGuard + - // uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing - // security fix for the lost-update race that stranded agent keys. - // identity-import-keyring: KeyringLockedScreen, RecoveryScreen, - // load_readonly + load_all_readonly + store_all for safe cross-service reads. - // sign-out wipe: delete_all() method removes the entire keychain blob under - // the interprocess advisory lock; +8 lines. Load-bearing; queued to split. - // signout-wipe phase 2: delete_all_with_legacy_cleanup replaces delete_all; - // reads blob keys + deletes per-key legacy entries to prevent resurrection. - // + regression test for per-key resurrection via real OS keychain. - // Net growth ~36+32 lines over prior cap. Load-bearing correctness fix. - // signout-wipe pass-2 (F2): delete_all_with_legacy_cleanup DPK deletes now - // observable (propagate real errors); verify_fully_wiped checks all three - // keychain shapes (main blob, DPK blob, per-key "identity"). +73 lines. - ["src-tauri/src/secret_store.rs", 1307], - // keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const - // to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix. - // +10 (1042 -> 1052): media_fetch_client with redirect::Policy::none() so a - // relay 3xx cannot forward the minted auth header cross-origin (SSRF fix). - // +16 (1052 -> 1068): extracted that client into `build_media_fetch_client()` - // -> Result so the fail-closed invariant is testable (no silent redirect- - // following fallback; startup panics loudly instead). The function belongs - // here beside `build_app_state` and its sibling client; its doc comment - // carries the load-bearing SSRF rationale. Extraction would only relocate, - // not reduce, the security-critical code. - // +5 (1068 -> 1073): merge with main, which independently added the - // managed_agent_profile_reconcile_enabled flag (field + doc + init) under - // its own 1042-line override. Union of two separately approved additions. - // +8 (1073 -> 1081): agents-everywhere pair re-key — managed_agent_processes - // and session_config_cache re-keyed by ManagedAgentRuntimeKey, the runtime - // transition lock doc broadened to cover all protected-PID transitions, and - // clear_agent_session_caches (per-pubkey retain) added alongside the - // per-key clear. Load-bearing identity-contract change; queued to split. - // +4 (1081 -> 1085): mesh recovery keeps one app-scoped state object beside - // the embedded runtime and coordinator. Probe/re-arm logic lives in - // mesh_llm/recovery.rs rather than growing AppState or command modules. - ["src-tauri/src/app_state.rs", 1085], - // multi-slot splitting + no-op suppression (#1309): the ReadStateManager - // class grew from ~700 lines to ~1019 with the addition of - // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, - // publishOneSlot, deleteExtraSlots, and the no-op suppression integration - // test. Load-bearing feature growth, queued to split publishSplitSlots path - // into readStateManagerSplit.ts. - ["src/features/channels/readState/readStateManager.ts", 1030], - // review feedback on #1492 restored the two-line load-bearing comment - // documenting why `lastMessageAt` must not be an `activeReadAt` fallback - // (reply-inclusive; would clear unread state early). The file was already - // at the 1000 ceiling; comment-only overage, not code growth. Queued to - // split with the rest of this list. - // member-agent-flags: messageProfiles merge + ref stabilisation split out to - // useMessageProfiles.ts, ratcheting 1002 -> 972 (under the 1000 default; - // entry kept as a ratchet). +7 rebase onto main (#1698 timeline-window - // growth), 972 -> 979. - ["src/features/channels/ui/ChannelScreen.tsx", 979], - // forced-unread persistence: markChannelUnread now writes through to - // forcedUnreadStore (localStorage) so the sidebar badge survives reload and - // the rail observer can read it. Three clear points added (markChannelRead, - // markAllChannelsRead, drainSyncedAdvances). Load-bearing fix, not generic - // debt growth. Queued to split with the rest of this list. - ["src/features/channels/useUnreadChannels.ts", 1022], - // Shared UI was added to this guard after splitting globals/markdown so - // large shared renderers cannot grow further while follow-up splits land. - // +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard). - ["src/shared/ui/markdown.tsx", 2152], - // +15 (2199 -> 2214): the video right-click Download/Copy menu's props, - // hook wiring, and render slot. The stateful menu logic (~52 lines) was - // extracted to useVideoContextMenu.tsx; what remains here is the component's - // public interface (downloadUrl/filename props) and cannot move out. - ["src/shared/ui/VideoPlayer.tsx", 2214], - ["src/shared/ui/sidebar.tsx", 1042], - // permission-outcome (fix #1381 regression): pendingPermissions state map, - // describePermissionOutcome helper, jsonRpcId key helper (handles both - // string and finite-number JSON-RPC ids per spec), and the acp_write - // response correlation branch are all tightly coupled to the existing - // request handler. Load-bearing logic growth, not generic debt. Queued to - // split into a dedicated permission module in the next transcript refactor. - // +123: observer parity — 4 new named session/update classifier cases - // (current_mode_update, usage_update, available_commands_update, - // config_option_update) + replaceLifecycleItem helper for usage coalescing + - // system-prompt ordering fix (turnId: null for per-channel items). - // +35: session/new reposition-on-refire fix — removeItem helper + - // upsertMetadata restart branch (remove+sealOpenMessages+push instead of - // replaceItem in-place) so system-prompt anchor moves to stream tail. - // Load-bearing feature growth; queued to split in next transcript refactor. - ["src/features/agents/ui/agentSessionTranscript.ts", 1202], - // catalog module; agent_models.rs retains the thin wrapper (~50 lines). - // File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess - // fallback. Queued to split into dedicated discovery modules. - // Kept activity-feed design fixture: realistic prompt context and tool-heavy - // chatter for render-class test/reference coverage. Queued to split with the - // rest of this list if it grows further. - // +2: baked build env folded under merged_env in both get_agent_models and - // discover_agent_models so in-process discovery sees baked provider config on - // a GUI-launched DMG (the discovery_env_with_baked_floor fold). - // +3: provider tri-state applied in update_managed_agent handler - // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). - // +8: harness_override thread-through in update_managed_agent so a deliberate - // Custom pin routes to update_time_agent_command_override (comment + call). - // +22 (1079 -> 1101, main): Finding 2 — model discovery now resolves through - // resolve_effective_model_provider instead of raw record bytes, plus - // apply_model_provider_prompt_update's linked-instance write-guard - // extraction and its regression tests. - // +4 (1101 -> 1105): rebase onto agents-everywhere — agents.rs function - // signatures updated for ManagedAgentRuntimeKey-keyed runtimes map. - // +1 (#2773): model_discovery_error helper routes dangling-harness - // resolution errors through user_facing_harness_error (sentence, not raw - // DANGLING_HARNESS_ID sentinel) for the get_agent_models surface. The PR's - // descriptor path also deletes saved_agent_model_discovery_config, whose - // callers now use resolve_effective_model_provider + the descriptor env - // directly (net wash after the merge of the deltas above). - // +38 (1114 -> 1152): agent_model_discovery_config extracted as a pure, - // test-bindable seam (struct + helper + docs) so the linked-agent - // regression test kills the stale-record mutation at get_agent_models' - // consumption point (review finding, Wren + Dawn). - ["src-tauri/src/commands/agent_models.rs", 1152], - // global-agent-config: get_agent_config_surface / write_agent_config_field / - // put_agent_session_config commands + GlobalAgentConfig serde types. New file - // in this PR; queued to split with the command module refactor. - // +17: baked-env-global-unify: BUZZ_AGENT_THINKING_EFFORT added to - // is_safe_to_reveal allowlist + baked_env_thinking_effort_is_unmasked test. - // +1: doctor-install-reliability: login_hint: None added to goose_runtime test stub. - // +1: doctor-install-reliability review fixes: auth_probe_args: None added to stub. - // +11 (1021 -> 1032): agents-everywhere pair re-key — session-cache reads in - // get_agent_config_surface derive the ManagedAgentRuntimeKey (relay-URL - // fallback resolution) and put_agent_session_config gains a relay_url param. - // Load-bearing identity plumbing; queued to split. - // +18 (1032 -> 1050): review fix — put_agent_session_config reads the pair - // relay from the harness-attached payload relayUrl (with effective-relay - // fallback for older harnesses) instead of a required arg the frontend - // wrapper never passed, which silently broke the session-config cache. - // +60 (1050 -> 1110): agent-config-resolver — resolve_config_surface now - // clears a linked instance's own system_prompt/model/provider before - // computing had_* so stale materialized snapshot bytes can never be tagged - // BuzzExplicit and shadow the definition/global fallthrough; the dead - // persona-model re-tag branch replaced; two new regression tests added. - ["src-tauri/src/commands/agent_config.rs", 1110], - // codex-install-auto-restart review-fixes: should_restart_after_install - // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy - // cache tests replaced with 6 pure availability_drift predicate tests; - // dead-pid non-happy-path added. All load-bearing correctness fixes. - // (+17 lines net vs previous 1330 limit; rustfmt expanded some call sites) - // Git Bash Doctor discovery exposes a narrow async Tauri command at the - // existing discovery boundary. The ten-line addition preserves the platform - // neutral frontend contract; split remains queued. - // Windows Doctor install fix: resolve_install_shell() + install_shell_command() - // returns Result (Windows Git Bash resolution, CREATE_NO_WINDOW, taskkill timeout - // kill), cli_install_commands_for_os() callsite, unit tests for shell selection - // and per-OS install command accessor. Load-bearing Windows platform support. - // +53: pass 2 — three cfg(windows) install shell tests (resolve succeeds with - // Git, error hint content, install_shell_command succeeds). - // +8: install_shell_from pure seam extracted for deterministic testing. - // +287: is_powershell_command + install_powershell_command + build_install_command - // route PowerShell CLI installs natively on Windows (bypasses Git Bash PATH - // poisoning that resolved GNU tar instead of bsdtar → Codex install failure). - // Includes unit tests for detection, routing, and -Command body preservation. - // +16: test_powershell_command_goose_catalog_dequoted proves the \$→$ escape - // fix for the Goose Windows installer (PR #2680 interaction with #2750). - // +10: pass an explicit PATH through Codex adapter install planning so unit - // tests avoid the process-global login-shell PATH cache. - // +59 (main): run install commands under `pipefail` so a failing `curl` in a - // `curl … | bash` install fails the `cli` step instead of being masked by - // `bash`'s exit 0, plus tests for the arg shape and the real pipeline status. - // +81 (main): install_shell_args re-exports the composed PATH inside the command - // body so login startup files can't clear or reorder it, plus an isolated - // hostile-profile regression the pure composition tests structurally miss. - // +42 (main): gate that re-export off Windows, where join_paths is `;`-separated - // and bash would collapse it into one entry, plus a platform-shape test. - // +126 (#2773): BYOH — save_custom_harness (validate, atomic write, return - // entry) + delete_custom_harness (id-guard, builtin reject, remove file) - // commands; discover_acp_providers updated to pass AppHandle + - // custom_harnesses dir. - // +30: BYOH F5 — atomic-write-file dep, original_id rename/delete support. - // +13: BYOH pass-2 C1 — warm_harness_registry_from_dir call in save and - // delete commands now verifies transactional registry refresh. - // +2: BYOH pass-2 I2 env round-trip — definition_env carried through save - // return value so the frontend immediately has the updated env. - // +1: rebase over main (#2680) — requires_external_cli: false added to - // save_custom_harness catalog entry construction (new required field). - ["src-tauri/src/commands/agent_discovery.rs", 2167], - // draft-persistence predicate: submit-time `loadDraft` check + inline comment - // + deps-array entry in submitMessage closes the never-persisted-boundary - // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to - // split MessageComposer into submit/edit/media sub-modules. - // +18: pendingImetaForPersistRef (local snapshot ref) + synchronous restore - // path writes in the draft-key effect body, fixing the image-drop bug on - // top-level nav switch (StrictMode simulate-unmount race on remount). - // +12 autoSubmitDraftKey/onAutoSubmitComplete props + onAutoSubmitCompleteRef - // + mount-only useEffect for the Drafts-panel "Send message" confirm-dialog - // flow. Load-bearing feature growth; queued to split with the rest of this - // list. - // +3: onLinkShortcutRef wiring (ref decl + editor option + assignment) for - // the ⌘K link-editor shortcut, mirroring the existing onEditLinkRef - // pattern. Queued to split with the rest of this list. - // +35: persistent audience scope/hook wiring and chip component handoff. The - // chip markup lives separately; remaining lines connect existing composer - // send state to the audience store. Queued with the existing split. - // +23: edit-to-add-mention notify (8ace8eed) — onEditSave/edit-branch - // mentionPubkeys threading + two snapshot refs (extractMentionPubkeys, - // ownerPubkey) feeding the newly-added-mentions diff. Diff logic itself - // lives in threading.ts (diffAddedMentionPubkeys); this is the minimal - // composer-side wiring. Queued to split with the rest of this list. - ["src/features/messages/ui/MessageComposer.tsx", 1114], - // global-agent-config: model-tuning section (BuzzAgentModelTuningFields via - // EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation - // + globalProvider threading into getPersonaProviderOptions. All load-bearing - // feature logic; queued to split with the rest of this list. - ["src/features/agents/ui/EditAgentDialog.tsx", 1088], - // global-agent-config rebase over #1639: AgentInstanceEditDialog (renamed from - // EditAgentDialog by #1639) gained initialFocus?/EditAgentFocusTarget prop - // threading from the deep-link focus feature, and isEditAgentProviderSaveValid - // extracted as a testable helper with originalRuntimeSupportsProvider to close - // the runtime-switch hole in Will's (b) providerValid gate narrowing. - // E2E-fix round: added globalProvider fallback to useRequiredCredentialState - // call site and buzz-agent auto-expand effect for model-tuning knob visibility. - // F1-fix: added globalEnvVars to useRequiredCredentialState so globally-satisfied - // credential keys are excluded from requiredEnvKeyMissing (display/gate parity). - // Feature logic, not generic debt. Approved override; still queued to split. - // +23 rebase onto #1667: behavioral quad fields (respond_to/parallelism/toolsets) - // plumbed through AgentInstanceEditDialog from PersonaAdvancedFields. - // +2 provider-aware effort: model/provider props threaded to BuzzAgentModelTuningFields. - // +15 provider/model dropdown fixes: useBakedBuildEnvKeysQuery + hideProviderIds - // for Databricks v1 gate; prospectiveRuntimeId default fallback for builtins. - // PR-B moves default/API-key derivation into shared hooks; the explicit - // hidden-key projection keeps the top-level secret out of Advanced rows. - // +6 (1195 -> 1201): rebase onto main — this PR's model-source label wiring - // lands on top of main's dialog growth. Queued to split. - ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1201], - // AgentDefinitionDialog grew past 1000 with the following load-bearing fixes: - // isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models); - // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); - // hideProviderIds computation for Databricks v1 gate. Queued to split. - ["src/features/agents/ui/AgentDefinitionDialog.tsx", 1035], -]); - await runFileSizeCheck({ projectRoot, rules, - overrides, label: "Desktop", - scriptPath: "desktop/scripts/check-file-sizes.mjs", }); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index e0732f62dc4..d4f7a4a2d4c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -991,6 +991,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "hex", "hmac 0.13.0", @@ -1009,7 +1010,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.4.26" +version = "0.5.1" dependencies = [ "anyhow", "arboard", @@ -2148,7 +2149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -5916,9 +5917,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.4" +version = "0.44.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cf5d15d70d1f8f4059e5f79923ac15891eb691d2843d01191e0585fb064d70" +checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" dependencies = [ "base64 0.22.1", "bech32", @@ -10149,7 +10150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d689544688d..8bb643fea36 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.4.26" +version = "0.5.1" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index a65b126a4d0..42c6812674e 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -483,21 +483,23 @@ pub async fn list_save_subscriptions( /// Does NOT purge already-archived event data — retention is decoupled in v1. /// GC of orphaned event rows happens in P4 purge commands, not here. #[tauri::command] -pub fn delete_save_subscription( +pub async fn delete_save_subscription( state: State<'_, AppState>, scope_type: ScopeType, scope_value: String, ) -> Result { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - store::delete_save_subscription( - &conn, - &identity_pk, - &relay_url, - scope_type.as_str(), - &scope_value, - ) + run_archive_db_task(move |conn| { + store::delete_save_subscription( + conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + ) + }) + .await } // ── read_archived_events ───────────────────────────────────────────────────── @@ -516,7 +518,7 @@ pub fn delete_save_subscription( /// newest-first order. Compound cursor `(before_created_at, before_id)` works /// identically to `read_archived_events`. #[tauri::command] -pub fn read_archived_observer_events_for_channel( +pub async fn read_archived_observer_events_for_channel( state: State<'_, AppState>, channel_id: String, before_created_at: Option, @@ -525,16 +527,18 @@ pub fn read_archived_observer_events_for_channel( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - store::read_archived_observer_events_for_channel( - &conn, - &identity_pk, - &relay_url, - &channel_id, - before_created_at, - before_id.as_deref(), - limit.unwrap_or(DEFAULT_READ_LIMIT), - ) + run_archive_db_task(move |conn| { + store::read_archived_observer_events_for_channel( + conn, + &identity_pk, + &relay_url, + &channel_id, + before_created_at, + before_id.as_deref(), + limit.unwrap_or(DEFAULT_READ_LIMIT), + ) + }) + .await } // ── index_observer_channel_id ───────────────────────────────────────────────── @@ -548,24 +552,26 @@ pub fn read_archived_observer_events_for_channel( /// /// Idempotent: rows that are already indexed are left unchanged. #[tauri::command] -pub fn index_observer_channel_id( +pub async fn index_observer_channel_id( state: State<'_, AppState>, entries: Vec, ) -> Result<(), String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - for entry in &entries { - store::upsert_observer_channel_index( - &conn, - &identity_pk, - &relay_url, - &entry.event_id, - entry.channel_id.as_deref(), - entry.created_at, - )?; - } - Ok(()) + run_archive_db_task(move |conn| { + for entry in &entries { + store::upsert_observer_channel_index( + conn, + &identity_pk, + &relay_url, + &entry.event_id, + entry.channel_id.as_deref(), + entry.created_at, + )?; + } + Ok(()) + }) + .await } /// A single (event_id, channel_id?, created_at) record used by @@ -591,21 +597,23 @@ pub struct ObserverChannelIndexEntry { /// Together these constitute the one-shot idempotent backfill required by the /// Slice 1 acceptance criteria (Thufir Pass 4). #[tauri::command] -pub fn read_unindexed_observer_rows( +pub async fn read_unindexed_observer_rows( state: State<'_, AppState>, ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - let rows = store::read_unindexed_observer_rows(&conn, &identity_pk, &relay_url)?; - Ok(rows - .into_iter() - .map(|(id, raw_json, created_at)| RawObserverRow { - id, - raw_json, - created_at, - }) - .collect()) + run_archive_db_task(move |conn| { + let rows = store::read_unindexed_observer_rows(conn, &identity_pk, &relay_url)?; + Ok(rows + .into_iter() + .map(|(id, raw_json, created_at)| RawObserverRow { + id, + raw_json, + created_at, + }) + .collect()) + }) + .await } /// Wire type returned by `read_unindexed_observer_rows`. diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 461fed5dbd4..5a26f0f6450 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -21,8 +21,7 @@ use crate::{ /// Subset of the goose file config exposed to the frontend for gate evaluation. /// -/// Only the fields the dialog gate needs — not the full `RuntimeConfigSurface`. -/// The gate uses this to know which requirements are already satisfied in the +/// Only the fields the dialog gate needs. This tracks which requirements are already satisfied in the /// harness config file, so it can show "Set in goose config" rather than /// surfacing a false missing-key marker. #[derive(Debug, Serialize)] @@ -685,8 +684,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -709,8 +710,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 84a00433c0b..d6429e04543 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,12 +1,11 @@ -use std::io::Read; use tauri::State; use crate::{ app_state::AppState, managed_agents::{ command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult, - ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, + RelayAgentInfo, DEFAULT_ACP_COMMAND, }, nostr_convert, relay::query_relay, @@ -26,7 +25,8 @@ fn active_installs() -> &'static std::sync::Mutex InstallStepResult { - run_install_with_retry( - INSTALL_MAX_ATTEMPTS, - |_attempt| run_install_command(step, command), - std::thread::sleep, - ) -} - -/// Core retry loop, decoupled from the real command runner and clock so it can -/// be unit-tested without spawning shells or sleeping. `run` receives the -/// 1-based attempt number. -fn run_install_with_retry( - max_attempts: u32, - mut run: impl FnMut(u32) -> InstallStepResult, - mut sleep: impl FnMut(std::time::Duration), -) -> InstallStepResult { - let mut attempt = 1; - loop { - let result = run(attempt); - if result.success || !install_failure_is_retryable(&result) || attempt >= max_attempts { - return if attempt > 1 && !result.success { - annotate_retry_attempts(result, attempt) - } else { - result - }; - } - sleep(install_retry_backoff(attempt)); - attempt += 1; - } -} - -/// Only retry commands that actually ran and exited nonzero — the signature of -/// a transient download failure. A missing exit code means the command timed -/// out or the shell failed to spawn, neither of which a retry is likely to fix. -fn install_failure_is_retryable(result: &InstallStepResult) -> bool { - !result.success && result.exit_code.is_some() -} - -/// Linear backoff: 3s before attempt 2, 6s before attempt 3. -fn install_retry_backoff(attempt: u32) -> std::time::Duration { - std::time::Duration::from_secs(3 * attempt as u64) -} - -/// Prefix the surfaced error so the UI shows the install was retried rather than -/// failed on a single unlucky attempt. -fn annotate_retry_attempts(mut result: InstallStepResult, attempts: u32) -> InstallStepResult { - result.stderr = format!( - "install failed after {attempts} attempts (retried with backoff)\n{}", - result.stderr - ); - result -} - /// Returns `true` when `command` is a Windows-native PowerShell invocation /// (i.e. begins with `powershell.exe`). These commands must NOT be routed /// through Git Bash: the Bash login shell prepends POSIX dirs to PATH, so @@ -1081,179 +1015,9 @@ fn build_install_command(command: &str) -> Result install_shell_command(command) } -fn run_install_command(step: &str, command: &str) -> InstallStepResult { - let mut cmd = match build_install_command(command) { - Ok(cmd) => cmd, - Err(hint) => { - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "no suitable shell found for install commands".to_string(), - exit_code: None, - hint: Some(hint), - }; - } - }; - - let mut child = match cmd - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(e) => { - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: format!("failed to spawn shell: {e}"), - exit_code: None, - hint: None, - }; - } - }; - - // 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 = String::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_string(&mut buf); - } - buf - }); - let stderr_thread = std::thread::spawn(move || { - let mut buf = String::new(); - if let Some(mut pipe) = stderr_pipe { - let _ = pipe.read_to_string(&mut buf); - } - buf - }); - - // Save the PID before moving `child` into the wait thread so we can - // kill the process on timeout. - let child_pid = child.id(); - - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let status = child.wait(); - let _ = tx.send(status); - }); - - // 5-minute timeout for install commands. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); - loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - // Timeout: kill the child process via its PID, then join all - // threads so nothing leaks. - #[cfg(unix)] - unsafe { - libc::kill(child_pid as i32, libc::SIGTERM); - } - #[cfg(windows)] - { - let _ = crate::managed_agents::taskkill_tree(child_pid); - } - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "install command timed out after 5 minutes".to_string(), - exit_code: None, - hint: None, - }; - } - - match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { - Ok(Ok(status)) => { - let _ = wait_thread.join(); - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr_raw = stderr_thread.join().unwrap_or_default(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: status.success(), - stdout: truncate_output(stdout), - stderr: truncate_output(stderr_raw), - exit_code: status.code(), - hint: None, - }; - } - Ok(Err(e)) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: format!("failed to check process status: {e}"), - exit_code: None, - hint: None, - }; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - // Still running; loop and check deadline again. - continue; - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - // wait_thread dropped sender without sending — shouldn't happen. - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "internal error: wait thread disconnected".to_string(), - exit_code: None, - hint: None, - }; - } - } - } -} - -/// Cap output to head + tail to avoid flooding the UI with large error dumps, -/// while preserving the most useful parts of the output. -fn truncate_output(s: String) -> String { - const HEAD: usize = 512; - const TAIL: usize = 1024; - const LIMIT: usize = HEAD + TAIL; - if s.len() <= LIMIT { - return s; - } - let head_end = floor_char_boundary(&s, HEAD); - let tail_start = floor_char_boundary(&s, s.len().saturating_sub(TAIL)); - let omitted = tail_start - head_end; - format!( - "{}\n... ({omitted} bytes omitted) ...\n{}", - &s[..head_end], - &s[tail_start..] - ) -} - -fn floor_char_boundary(s: &str, mut index: usize) -> usize { - index = index.min(s.len()); - while index > 0 && !s.is_char_boundary(index) { - index -= 1; - } - index -} +// ── install command execution ───────────────────────────────────────────────── +mod install_exec; +use install_exec::run_install_command_with_retry; // ── managed Node/npm runtime ────────────────────────────────────────────────── mod managed_node; @@ -1389,7 +1153,8 @@ mod tests { /// plan_adapter_install is the pure install-plan seam used by /// install_acp_runtime_blocking. These tests verify: /// - A 0.x binary (AdapterOutdated) → uninstall-then-install sequence returned - /// - A 1.x binary (Available) → None (no reinstall) + /// - A current 1.x binary (Available) → None (no reinstall) + /// - A 1.x binary below the floor → install plan returned /// - Missing binary (None path) → catalog install commands returned #[cfg(unix)] #[test] @@ -1429,10 +1194,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let bin = dir.path().join("codex-acp"); - // Simulate 1.x adapter: outputs version and exits 0 + // Simulate the minimum supported adapter version. std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) @@ -1443,7 +1208,32 @@ mod tests { assert!( plan.is_none(), - "1.x codex adapter must not trigger install plan (no reinstall needed)" + "current codex adapter must not trigger install plan (no reinstall needed)" + ); + } + + #[cfg(unix)] + #[test] + fn test_plan_adapter_install_updates_older_1x_codex_binary() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("codex-acp"); + // A 1.x adapter below MIN_CODEX_ACP_VERSION must still be reinstalled. + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod script"); + + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); + + assert!( + plan.is_some(), + "older 1.x codex adapter must trigger update plan" ); } @@ -2031,128 +1821,6 @@ mod tests { "Goose catalog command must dequote with bare $env: (no backslash before $)" ); } - - // ── install retry ───────────────────────────────────────────────────────── - - /// Build an `InstallStepResult` with just the fields the retry loop reads. - fn step_result(success: bool, exit_code: Option, stderr: &str) -> InstallStepResult { - InstallStepResult { - step: "cli".to_string(), - command: "curl … | bash".to_string(), - success, - stdout: String::new(), - stderr: stderr.to_string(), - exit_code, - hint: None, - } - } - - #[test] - fn test_retryable_only_for_nonzero_exit() { - // Ran to completion but exited nonzero — the transient-download signature. - assert!(install_failure_is_retryable(&step_result( - false, - Some(1), - "" - ))); - // No exit code — timeout or shell-never-spawned; retry won't help. - assert!(!install_failure_is_retryable(&step_result(false, None, ""))); - // Success is never retryable. - assert!(!install_failure_is_retryable(&step_result( - true, - Some(0), - "" - ))); - } - - #[test] - fn test_retry_backoff_is_linear() { - assert_eq!(install_retry_backoff(1), std::time::Duration::from_secs(3)); - assert_eq!(install_retry_backoff(2), std::time::Duration::from_secs(6)); - } - - #[test] - fn test_retry_stops_on_first_success() { - let mut calls = 0; - let mut sleeps = 0; - let result = run_install_with_retry( - 3, - |_| { - calls += 1; - step_result(true, Some(0), "") - }, - |_| sleeps += 1, - ); - assert!(result.success); - assert_eq!(calls, 1, "a first-attempt success must not re-run"); - assert_eq!(sleeps, 0, "no backoff sleep when nothing is retried"); - } - - #[test] - fn test_retry_recovers_after_transient_failure() { - let mut calls = 0; - let result = run_install_with_retry( - 3, - |attempt| { - calls += 1; - // Fail the first attempt with a nonzero exit, then succeed. - step_result(attempt >= 2, Some(if attempt >= 2 { 0 } else { 1 }), "blip") - }, - |_| {}, - ); - assert!(result.success); - assert_eq!(calls, 2, "should retry once then succeed"); - // A recovered install must not carry the retry-failure annotation. - assert!(!result.stderr.contains("attempts")); - } - - #[test] - fn test_retry_does_not_retry_unretryable_failure() { - let mut calls = 0; - let result = run_install_with_retry( - 3, - |_| { - calls += 1; - step_result(false, None, "timed out") - }, - |_| {}, - ); - assert!(!result.success); - assert_eq!(calls, 1, "a failure with no exit code must not be retried"); - assert_eq!( - result.stderr, "timed out", - "unretried failure is unannotated" - ); - } - - #[test] - fn test_retry_exhausts_attempts_and_annotates() { - let mut calls = 0; - let mut sleeps = 0; - let result = run_install_with_retry( - 3, - |_| { - calls += 1; - step_result(false, Some(1), "download failed") - }, - |_| sleeps += 1, - ); - assert!(!result.success); - assert_eq!(calls, 3, "must try exactly max_attempts times"); - assert_eq!( - sleeps, 2, - "backoff sleeps between attempts, not after the last" - ); - assert!( - result.stderr.contains("after 3 attempts"), - "exhausted retries must surface the attempt count, got: {}", - result.stderr - ); - assert!( - result.stderr.contains("download failed"), - "original stderr must be preserved" - ); - } } /// Returns the Windows-only Git Bash prerequisite used by buzz-agent's shell MCP. diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs b/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs new file mode 100644 index 00000000000..63163ceadc5 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs @@ -0,0 +1,457 @@ +//! Execution of runtime install commands: spawning the built command, +//! draining its output under a timeout, and retrying transient failures. +//! +//! Command *construction* stays in the parent module (`install_shell_command`, +//! `install_powershell_command`, `build_install_command`); this module owns +//! only what happens once a `Command` exists. + +use std::io::Read; + +use crate::managed_agents::InstallStepResult; + +/// Maximum number of attempts for a transient-looking install command. +const INSTALL_MAX_ATTEMPTS: u32 = 3; + +/// Run an install command, retrying transient failures with backoff. +/// +/// Runtime installs pull artifacts over the network — Goose's `curl … | bash` +/// fetches a native release-asset tarball from GitHub's CDN with no retry of +/// its own, and the npm adapter installs hit the registry. A single blip there +/// currently fails onboarding outright. This retries a command that ran to +/// completion but exited nonzero (the transient-download signature) up to +/// `INSTALL_MAX_ATTEMPTS` times. Failures with no exit code — a timeout or a +/// shell that never spawned — are not retried, since re-running them just costs +/// the user more time without a plausible path to success. +pub(super) fn run_install_command_with_retry(step: &str, command: &str) -> InstallStepResult { + run_install_with_retry( + INSTALL_MAX_ATTEMPTS, + |_attempt| run_install_command(step, command), + std::thread::sleep, + ) +} + +/// Core retry loop, decoupled from the real command runner and clock so it can +/// be unit-tested without spawning shells or sleeping. `run` receives the +/// 1-based attempt number. +fn run_install_with_retry( + max_attempts: u32, + mut run: impl FnMut(u32) -> InstallStepResult, + mut sleep: impl FnMut(std::time::Duration), +) -> InstallStepResult { + let mut attempt = 1; + loop { + let result = run(attempt); + if result.success || !install_failure_is_retryable(&result) || attempt >= max_attempts { + return if attempt > 1 && !result.success { + annotate_retry_attempts(result, attempt) + } else { + result + }; + } + sleep(install_retry_backoff(attempt)); + attempt += 1; + } +} + +/// Only retry commands that actually ran and exited nonzero — the signature of +/// a transient download failure. A missing exit code means the command timed +/// out or the shell failed to spawn, neither of which a retry is likely to fix. +fn install_failure_is_retryable(result: &InstallStepResult) -> bool { + !result.success && result.exit_code.is_some() +} + +/// Linear backoff: 3s before attempt 2, 6s before attempt 3. +fn install_retry_backoff(attempt: u32) -> std::time::Duration { + std::time::Duration::from_secs(3 * attempt as u64) +} + +/// Prefix the surfaced error so the UI shows the install was retried rather than +/// failed on a single unlucky attempt. +fn annotate_retry_attempts(mut result: InstallStepResult, attempts: u32) -> InstallStepResult { + result.stderr = format!( + "install failed after {attempts} attempts (retried with backoff)\n{}", + result.stderr + ); + result +} + +/// Build the install command and point it at a writable working directory. +/// +/// A packaged desktop launch inherits `/` as its working directory, and +/// installers that write relative to the CWD then fail on a read-only root, so +/// they run from Buzz's own default workdir instead (#2245). +/// +/// This is the only command builder [`run_install_command`] calls, so anything +/// it spawns is guaranteed to carry the workdir — which is what makes the +/// working directory assertable without spawning a real login shell. +fn prepare_install_command(command: &str) -> Result { + let mut cmd = super::build_install_command(command)?; + if let Some(workdir) = crate::managed_agents::default_agent_workdir() { + cmd.current_dir(workdir); + } + Ok(cmd) +} + +fn run_install_command(step: &str, command: &str) -> InstallStepResult { + let mut cmd = match prepare_install_command(command) { + Ok(cmd) => cmd, + Err(hint) => { + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "no suitable shell found for install commands".to_string(), + exit_code: None, + hint: Some(hint), + }; + } + }; + + let mut child = match cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(e) => { + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: format!("failed to spawn shell: {e}"), + exit_code: None, + hint: None, + }; + } + }; + + // 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 = String::new(); + if let Some(mut pipe) = stdout_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + let stderr_thread = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut pipe) = stderr_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + + // Save the PID before moving `child` into the wait thread so we can + // kill the process on timeout. + let child_pid = child.id(); + + let (tx, rx) = std::sync::mpsc::channel(); + let wait_thread = std::thread::spawn(move || { + let status = child.wait(); + let _ = tx.send(status); + }); + + // 5-minute timeout for install commands. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + // Timeout: kill the child process via its PID, then join all + // threads so nothing leaks. + #[cfg(unix)] + unsafe { + libc::kill(child_pid as i32, libc::SIGTERM); + } + #[cfg(windows)] + { + let _ = crate::managed_agents::taskkill_tree(child_pid); + } + drop(rx); + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "install command timed out after 5 minutes".to_string(), + exit_code: None, + hint: None, + }; + } + + match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { + Ok(Ok(status)) => { + let _ = wait_thread.join(); + let stdout = stdout_thread.join().unwrap_or_default(); + let stderr_raw = stderr_thread.join().unwrap_or_default(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: status.success(), + stdout: truncate_output(stdout), + stderr: truncate_output(stderr_raw), + exit_code: status.code(), + hint: None, + }; + } + Ok(Err(e)) => { + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: format!("failed to check process status: {e}"), + exit_code: None, + hint: None, + }; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Still running; loop and check deadline again. + continue; + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + // wait_thread dropped sender without sending — shouldn't happen. + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "internal error: wait thread disconnected".to_string(), + exit_code: None, + hint: None, + }; + } + } + } +} + +/// Cap output to head + tail to avoid flooding the UI with large error dumps, +/// while preserving the most useful parts of the output. +fn truncate_output(s: String) -> String { + const HEAD: usize = 512; + const TAIL: usize = 1024; + const LIMIT: usize = HEAD + TAIL; + if s.len() <= LIMIT { + return s; + } + let head_end = floor_char_boundary(&s, HEAD); + let tail_start = floor_char_boundary(&s, s.len().saturating_sub(TAIL)); + let omitted = tail_start - head_end; + format!( + "{}\n... ({omitted} bytes omitted) ...\n{}", + &s[..head_end], + &s[tail_start..] + ) +} + +fn floor_char_boundary(s: &str, mut index: usize) -> usize { + index = index.min(s.len()); + while index > 0 && !s.is_char_boundary(index) { + index -= 1; + } + index +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── install retry ───────────────────────────────────────────────────────── + + /// Build an `InstallStepResult` with just the fields the retry loop reads. + fn step_result(success: bool, exit_code: Option, stderr: &str) -> InstallStepResult { + InstallStepResult { + step: "cli".to_string(), + command: "curl … | bash".to_string(), + success, + stdout: String::new(), + stderr: stderr.to_string(), + exit_code, + hint: None, + } + } + + #[test] + fn test_retryable_only_for_nonzero_exit() { + // Ran to completion but exited nonzero — the transient-download signature. + assert!(install_failure_is_retryable(&step_result( + false, + Some(1), + "" + ))); + // No exit code — timeout or shell-never-spawned; retry won't help. + assert!(!install_failure_is_retryable(&step_result(false, None, ""))); + // Success is never retryable. + assert!(!install_failure_is_retryable(&step_result( + true, + Some(0), + "" + ))); + } + + #[test] + fn test_retry_backoff_is_linear() { + assert_eq!(install_retry_backoff(1), std::time::Duration::from_secs(3)); + assert_eq!(install_retry_backoff(2), std::time::Duration::from_secs(6)); + } + + #[test] + fn test_retry_stops_on_first_success() { + let mut calls = 0; + let mut sleeps = 0; + let result = run_install_with_retry( + 3, + |_| { + calls += 1; + step_result(true, Some(0), "") + }, + |_| sleeps += 1, + ); + assert!(result.success); + assert_eq!(calls, 1, "a first-attempt success must not re-run"); + assert_eq!(sleeps, 0, "no backoff sleep when nothing is retried"); + } + + #[test] + fn test_retry_recovers_after_transient_failure() { + let mut calls = 0; + let result = run_install_with_retry( + 3, + |attempt| { + calls += 1; + // Fail the first attempt with a nonzero exit, then succeed. + step_result(attempt >= 2, Some(if attempt >= 2 { 0 } else { 1 }), "blip") + }, + |_| {}, + ); + assert!(result.success); + assert_eq!(calls, 2, "should retry once then succeed"); + // A recovered install must not carry the retry-failure annotation. + assert!(!result.stderr.contains("attempts")); + } + + #[test] + fn test_retry_does_not_retry_unretryable_failure() { + let mut calls = 0; + let result = run_install_with_retry( + 3, + |_| { + calls += 1; + step_result(false, None, "timed out") + }, + |_| {}, + ); + assert!(!result.success); + assert_eq!(calls, 1, "a failure with no exit code must not be retried"); + assert_eq!( + result.stderr, "timed out", + "unretried failure is unannotated" + ); + } + + #[test] + fn test_retry_exhausts_attempts_and_annotates() { + let mut calls = 0; + let mut sleeps = 0; + let result = run_install_with_retry( + 3, + |_| { + calls += 1; + step_result(false, Some(1), "download failed") + }, + |_| sleeps += 1, + ); + assert!(!result.success); + assert_eq!(calls, 3, "must try exactly max_attempts times"); + assert_eq!( + sleeps, 2, + "backoff sleeps between attempts, not after the last" + ); + assert!( + result.stderr.contains("after 3 attempts"), + "exhausted retries must surface the attempt count, got: {}", + result.stderr + ); + assert!( + result.stderr.contains("download failed"), + "original stderr must be preserved" + ); + } + + // ── install working directory ───────────────────────────────────────────── + + /// Every install child must run from Buzz's writable default workdir. A + /// packaged launch inherits `/`, where installers that write relative to + /// the CWD fail on a read-only root (#2245). + /// + /// Asserts the prepared `Command` rather than spawning one: `run_install_command` + /// would start a real login shell, which is neither hermetic nor fast. + #[test] + fn test_prepared_install_command_uses_default_workdir() { + let expected = crate::managed_agents::default_agent_workdir() + .expect("a default workdir must resolve on any test host"); + + let cmd = prepare_install_command("echo test").expect("install shell must resolve"); + + assert_eq!(cmd.get_current_dir(), Some(expected.as_path())); + } + + // ── output truncation ───────────────────────────────────────────────────── + + /// Output within the cap is passed through byte-for-byte — no marker, no loss. + #[test] + fn test_truncate_output_leaves_short_output_untouched() { + let short = "a".repeat(1536); + + assert_eq!(truncate_output(short.clone()), short); + } + + /// Over the cap, both ends survive and the middle is replaced by a marker + /// naming the omitted byte count — the head keeps the command's opening + /// context and the tail keeps the error that usually trails. + #[test] + fn test_truncate_output_keeps_head_and_tail_with_marker() { + let input = format!( + "{}{}{}", + "H".repeat(512), + "M".repeat(4000), + "T".repeat(1024) + ); + + let out = truncate_output(input); + + assert!(out.starts_with(&"H".repeat(512))); + assert!(out.ends_with(&"T".repeat(1024))); + assert!( + out.contains("... (4000 bytes omitted) ..."), + "marker must name the omitted byte count, got: {out}" + ); + } + + /// Truncation must not split a multi-byte character. Cutting mid-codepoint + /// would panic on the slice; the boundary floor prevents it. + #[test] + fn test_truncate_output_does_not_split_multibyte_characters() { + // "é" is 2 bytes, so every candidate cut index lands mid-character. + let input = "é".repeat(4000); + + let out = truncate_output(input); + + assert!(out.contains("bytes omitted"), "input must exceed the cap"); + assert!(!out.contains('\u{fffd}'), "no replacement chars: {out}"); + } +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index 2d7e13d1d76..72108f02910 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -5,7 +5,7 @@ use std::{io::Read, io::Write}; use crate::managed_agents::{is_npm_global_install, InstallStepResult}; -const MANAGED_NODE_VERSION: &str = "v24.11.0"; +const MANAGED_NODE_VERSION: &str = "v24.18.0"; const MANAGED_NODE_MAX_BYTES: u64 = 90 * 1024 * 1024; #[derive(Debug, Clone, Copy)] @@ -18,43 +18,43 @@ struct ManagedNodeArtifact { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "darwin-arm64", - filename: "node-v24.11.0-darwin-arm64.tar.gz", - sha256: "0be2ab2816a4fa02d1acff014a434f29f56d8d956f5af6a98b70ced6c5f4d201", + filename: "node-v24.18.0-darwin-arm64.tar.gz", + sha256: "e1a97e14c99c803e96c7339403282ea05a499c32f8d83defe9ef5ec66f979ed1", }); #[cfg(all(target_os = "macos", target_arch = "x86_64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "darwin-x64", - filename: "node-v24.11.0-darwin-x64.tar.gz", - sha256: "3884671e87f46f773832d98a0a6cabcc5ec4f637084f0f3515b69e66ea27f2f1", + filename: "node-v24.18.0-darwin-x64.tar.gz", + sha256: "dfd0dbd3e721503434df7b7205e719f61b3a3a31b2bcf9729b8b91fea240f080", }); #[cfg(all(target_os = "linux", target_arch = "x86_64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "linux-x64", - filename: "node-v24.11.0-linux-x64.tar.gz", - sha256: "b3c071cdf47aab867c3b2aa287257df12ec5d7c962bf922b32fd33226c4295fd", + filename: "node-v24.18.0-linux-x64.tar.gz", + sha256: "783130984963db7ba9cbd01089eaf2c2efb055c7c1693c943174b967b3050cb8", }); #[cfg(all(target_os = "linux", target_arch = "aarch64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "linux-arm64", - filename: "node-v24.11.0-linux-arm64.tar.gz", - sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d", + filename: "node-v24.18.0-linux-arm64.tar.gz", + sha256: "6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508", }); #[cfg(all(target_os = "windows", target_arch = "x86_64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "win-x64", - filename: "node-v24.11.0-win-x64.zip", - sha256: "1054540bce22b54ec7e50ebc078ec5d090700a77657607a58f6a64df21f49fdd", + filename: "node-v24.18.0-win-x64.zip", + sha256: "0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821", }); #[cfg(all(target_os = "windows", target_arch = "aarch64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "win-arm64", - filename: "node-v24.11.0-win-arm64.zip", - sha256: "12d3b1aa9696b7411e115a4fa2aef57f95560b5ee16bb62cd69843e535ec72be", + filename: "node-v24.18.0-win-arm64.zip", + sha256: "f274669adb93b1fd0fbf8f21fd078609e9dcc84333d4f2718d2dde3f9a161a01", }); #[cfg(not(any( @@ -615,9 +615,9 @@ mod tests { #[test] fn test_validate_zip_accepts_normal_entries() { let tmp = make_zip_with_entries(&[ - "node-v24.11.0-win-x64/node.exe", - "node-v24.11.0-win-x64/npm.cmd", - "node-v24.11.0-win-x64/npm", + "node-v24.18.0-win-x64/node.exe", + "node-v24.18.0-win-x64/npm.cmd", + "node-v24.18.0-win-x64/npm", ]); let file = std::fs::File::open(tmp.path()).unwrap(); let archive = zip::ZipArchive::new(file).unwrap(); @@ -677,7 +677,7 @@ mod tests { #[test] fn test_validate_zip_rejects_backslash_traversal() { // Path traversal using Windows separator — must reject on every host. - let tmp = make_zip_with_entries(&["node-v24.11.0-win-x64\\..\\..\\evil"]); + let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); let file = std::fs::File::open(tmp.path()).unwrap(); let archive = zip::ZipArchive::new(file).unwrap(); let err = validate_managed_node_zip_entries(&archive).unwrap_err(); diff --git a/desktop/src-tauri/src/commands/agent_logs.rs b/desktop/src-tauri/src/commands/agent_logs.rs index 25d3d4d1c41..273654e32ac 100644 --- a/desktop/src-tauri/src/commands/agent_logs.rs +++ b/desktop/src-tauri/src/commands/agent_logs.rs @@ -3,7 +3,7 @@ use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, managed_agents::{ - load_managed_agents, managed_agent_log_path, read_log_tail, BackendKind, + latest_managed_agent_log_path, load_managed_agents, read_log_tail, BackendKind, ManagedAgentLogResponse, }, }; @@ -29,7 +29,7 @@ pub async fn get_managed_agent_log( return Err("logs are not available for remote agents".to_string()); } - let log_path = managed_agent_log_path(&app, &pubkey)?; + let log_path = latest_managed_agent_log_path(&app, &pubkey)?; Ok(ManagedAgentLogResponse { content: read_log_tail(&log_path, line_count.unwrap_or(120) as usize)?, log_path: log_path.display().to_string(), diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index d98460109f5..b65f2409005 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -394,8 +394,10 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b5ebeca4f6..0758fc3aac5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,8 +6,8 @@ use crate::{ managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, - provider_deploy, resolve_provider_binary, save_managed_agents, start_managed_agent_process, + load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, + resolve_provider_binary, save_managed_agents, start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, @@ -18,8 +18,7 @@ use crate::{ util::now_iso, }; -/// Read the workspace owner's pubkey hex from app state without holding the -/// lock for longer than necessary. Used to populate `BUZZ_ACP_AGENT_OWNER` +/// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. pub(super) fn workspace_owner_hex(state: &AppState) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; @@ -45,52 +44,15 @@ pub(super) fn retain_managed_agent_pending( state: &AppState, record: &ManagedAgentRecord, ) { - use crate::managed_agents::{ - agent_events::{agent_event_content, build_agent_event}, - persona_events::monotonic_created_at, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; + use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - // The published content is the opt-IN projection JSON, independent of - // signing and created_at. Compute it once to drive the no-republish - // guard without signing twice. - let content = serde_json::to_string(&agent_event_content(record)) - .map_err(|e| format!("failed to serialize managed-agent content: {e}"))?; - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let existing = - get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; - // Skip re-publishing when the projection is unchanged: a start/stop - // or any edit that touched only excluded runtime/local fields - // produces an identical projection, so it is a no-op — operational - // churn never re-enqueues a publish. - if existing.as_ref().is_some_and(|row| row.content == content) { - return Ok(()); - } - // Monotonic created_at: bump past the retained head (NIP-AP step 3). - let event = build_agent_event(record)? - .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign managed-agent event: {e}"))?; - (owner_pubkey, event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + // Shared engine with the boot-time reconcile: projection content diff + // (no republish for runtime-only churn) + monotonic created_at bump + // past the retained head (NIP-AP step 3). + retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) })(); if let Err(e) = result { eprintln!("buzz-desktop: agent-retain: {e}"); @@ -126,15 +88,12 @@ pub(super) fn tombstone_managed_agent_pending( const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let event = build_agent_delete(agent_pubkey, &owner_pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; - (owner_pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; retain_event( &conn, @@ -220,13 +179,10 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let event = build_agent_archive_request(&keys, agent_pubkey)?; - (owner_pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?; + let conn = open_retention_db(&scope.db_path)?; retain_event( &conn, &RetainedEvent { @@ -941,8 +897,10 @@ pub async fn create_managed_agent( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index e32fc1cfe4b..03389d1d18b 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -53,8 +53,10 @@ fn bare_agent_record( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -75,8 +77,10 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 33783c05a57..2840c0ade68 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -135,23 +135,29 @@ pub async fn sign_event( } #[tauri::command] -pub fn decrypt_observer_event( +pub async fn decrypt_observer_event( event_json: String, state: State<'_, AppState>, ) -> Result { let keys = state.signing_keys()?; - let event = Event::from_json(event_json).map_err(|error| format!("invalid event: {error}"))?; - // Defense-in-depth: verify event ID and signature before decrypting. - if !event.verify_id() { - return Err("observer event has invalid ID".into()); - } - if !event.verify_signature() { - return Err("observer event has invalid signature".into()); - } + tauri::async_runtime::spawn_blocking(move || { + let event = + Event::from_json(event_json).map_err(|error| format!("invalid event: {error}"))?; - buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event) - .map_err(|error| format!("decrypt observer event failed: {error}")) + // Defense-in-depth: verify event ID and signature before decrypting. + if !event.verify_id() { + return Err("observer event has invalid ID".into()); + } + if !event.verify_signature() { + return Err("observer event has invalid signature".into()); + } + + buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event) + .map_err(|error| format!("decrypt observer event failed: {error}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/join_policy.rs b/desktop/src-tauri/src/commands/join_policy.rs new file mode 100644 index 00000000000..d2a357c4f94 --- /dev/null +++ b/desktop/src-tauri/src/commands/join_policy.rs @@ -0,0 +1,193 @@ +use futures_util::StreamExt; +use serde_json::Value; +use std::time::Duration; +use url::Url; + +// Each relay policy document is capped at 256 KiB before JSON encoding. Four +// MiB covers two maximally escaped documents plus the response envelope. +const MAX_JOIN_POLICY_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const JOIN_POLICY_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +fn join_policy_url(relay_url: &str) -> Result { + let mut url = Url::parse(relay_url.trim()).map_err(|_| "invalid relay URL".to_string())?; + let http_scheme = match url.scheme() { + "wss" => "https", + "ws" => "http", + _ => return Err("relay URL must use ws:// or wss://".to_string()), + }; + url.set_scheme(http_scheme) + .map_err(|_| "invalid relay URL scheme".to_string())?; + + if !url.username().is_empty() || url.password().is_some() { + return Err("relay URL must not contain credentials".to_string()); + } + + let base_path = url.path().trim_end_matches('/'); + url.set_path(&format!("{base_path}/api/join-policy")); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +/// Fetch an arbitrary relay's optional join policy through native networking. +#[tauri::command] +pub async fn fetch_join_policy(relay_url: String) -> Result, String> { + let url = join_policy_url(&relay_url)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("failed to build join policy client: {error}"))?; + let response = client + .get(url) + .timeout(JOIN_POLICY_REQUEST_TIMEOUT) + .send() + .await + .map_err(|error| format!("join policy request failed: {error}"))?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let body = read_join_policy_json(response).await?; + Ok(body + .get("policy") + .filter(|policy| !policy.is_null()) + .cloned()) +} + +async fn read_join_policy_json(response: reqwest::Response) -> Result { + if response + .content_length() + .is_some_and(|length| length > MAX_JOIN_POLICY_RESPONSE_BYTES as u64) + { + return Err("relay returned oversized join policy".to_string()); + } + + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("reading join policy failed: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > MAX_JOIN_POLICY_RESPONSE_BYTES { + return Err("relay returned oversized join policy".to_string()); + } + bytes.extend_from_slice(&chunk); + } + + serde_json::from_slice(&bytes).map_err(|_| "relay returned malformed join policy".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{Body, Bytes}, + http::Response, + response::Redirect, + routing::get, + Json, Router, + }; + use std::convert::Infallible; + + async fn test_relay(router: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("ws://{address}") + } + + #[test] + fn converts_relay_urls_to_join_policy_urls() { + assert_eq!( + join_policy_url("wss://relay.example.com/") + .unwrap() + .as_str(), + "https://relay.example.com/api/join-policy" + ); + assert_eq!( + join_policy_url("ws://localhost:3000/base") + .unwrap() + .as_str(), + "http://localhost:3000/base/api/join-policy" + ); + } + + #[test] + fn rejects_non_relay_schemes_and_credentials() { + assert!(join_policy_url("https://relay.example.com").is_err()); + assert!(join_policy_url("wss://user:secret@relay.example.com").is_err()); + } + + #[tokio::test] + async fn reads_an_optional_policy_without_webview_cors() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + Json(serde_json::json!({ + "policy": { + "terms_markdown": "# Terms", + "age_attestation_required": true, + "version": "v1" + } + })) + }), + )) + .await; + + let policy = fetch_join_policy(relay_url).await.unwrap().unwrap(); + assert_eq!(policy["version"], "v1"); + assert_eq!(policy["age_attestation_required"], true); + } + + #[tokio::test] + async fn refuses_join_policy_redirects() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { Redirect::temporary("http://127.0.0.1:1/private") }), + )) + .await; + + assert_eq!(fetch_join_policy(relay_url).await.unwrap_err(), "HTTP 307"); + } + + #[tokio::test] + async fn rejects_declared_oversized_join_policy() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + Response::builder() + .body(Body::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1])) + .unwrap() + }), + )) + .await; + + assert_eq!( + fetch_join_policy(relay_url).await.unwrap_err(), + "relay returned oversized join policy" + ); + } + + #[tokio::test] + async fn rejects_chunked_oversized_join_policy() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + let chunk = Bytes::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1]); + Body::from_stream(futures_util::stream::once(async move { + Ok::<_, Infallible>(chunk) + })) + }), + )) + .await; + + assert_eq!( + fetch_join_policy(relay_url).await.unwrap_err(), + "relay returned oversized join policy" + ); + } +} diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index bf8692ff700..ed3b3402388 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -411,7 +411,7 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { } async fn send_upload_attempt( - state: &State<'_, AppState>, + state: &AppState, url: String, auth_header: &str, mime: &str, @@ -455,10 +455,22 @@ async fn send_upload_attempt( response.map_err(|error| classify_request_error(&error)) } +pub(crate) async fn upload_image_bytes( + body: Vec, + state: &AppState, +) -> Result { + let mime = detect_and_validate_mime(&body)?; + if !mime.starts_with("image/") { + return Err("profile avatar must be an image".to_string()); + } + let body = sanitize_image_for_upload(body, &mime)?; + do_upload(body, &mime, state, None).await +} + async fn do_upload( body: Vec, mime: &str, - state: &State<'_, AppState>, + state: &AppState, progress: Option<(tauri::AppHandle, String)>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); @@ -559,7 +571,7 @@ pub async fn upload_media( /// files from ever leaving the client on image-only surfaces. async fn process_picked_path( path: std::path::PathBuf, - state: &State<'_, AppState>, + state: &AppState, images_only: bool, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 016865878e0..6086af218b6 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -49,9 +49,13 @@ fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { return Err("download URL must match the relay origin".to_string()); } - // Path must be /media/{filename}. + // Path must contain a /media/{filename} segment. The segment may be preceded + // by a base path when the relay is served under one (BUZZ_BASE_PATH), e.g. + // /relay/media/.png. Requiring the full segment still rejects + // near-misses such as /media-evil/, and the origin check above already + // pins the request to the relay. let path = parsed.path(); - if !path.starts_with("/media/") { + if !path.contains("/media/") { return Err("download URL must be a /media/ path".to_string()); } @@ -610,6 +614,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -659,6 +664,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -704,6 +710,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -788,6 +795,34 @@ mod tests { ); } + #[test] + fn test_validate_download_url_accepts_base_path_prefixed_media() { + // A relay served under BUZZ_BASE_PATH hosts media at /media/. + assert!(validate_download_url( + "https://relay.example.com/relay/media/abc123.png", + RELAY_BASE, + ) + .is_ok()); + assert!(validate_download_url( + "https://relay.example.com/buzz/relay/media/abc123.png", + RELAY_BASE, + ) + .is_ok()); + } + + #[test] + fn test_validate_download_url_rejects_media_lookalike_segment() { + // The full /media/ segment is still required, prefix or not. + let result = + validate_download_url("https://relay.example.com/media-evil/abc123.png", RELAY_BASE); + assert!(result.is_err()); + let prefixed = validate_download_url( + "https://relay.example.com/relay/media-evil/abc123.png", + RELAY_BASE, + ); + assert!(prefixed.is_err()); + } + #[test] fn test_validate_download_url_non_relay_origin_rejected() { let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index f2593ff9e0f..734d8f5dc8a 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -158,6 +158,7 @@ mod tests { version: 1, definition: AgentSnapshotDefinition { name: "Tree Trunks".to_string(), + source_is_builtin: false, system_prompt: Some("You are a helpful agent.".to_string()), runtime: Some("goose".to_string()), model: None, diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index afe94cdfe62..b7c37bec3df 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -138,7 +138,14 @@ pub async fn get_feed( }) } -fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) -> serde_json::Value { +fn build_search_messages_filter( + q: &str, + cap: u32, + channel_id: Option<&str>, + authors: Option<&[String]>, + since: Option, + until: Option, +) -> serde_json::Value { let mut filter = serde_json::Map::new(); filter.insert( "kinds".to_string(), @@ -153,6 +160,25 @@ fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) -> if let Some(cid) = channel_id { filter.insert("#h".to_string(), serde_json::json!([cid])); } + // Optional operators from the desktop search parser (#2853). The relay + // already maps authors/since/until onto FTS; search remains never the + // access boundary (hits are refetched and re-authorized). + if let Some(authors) = authors { + let cleaned: Vec<&str> = authors + .iter() + .map(|a| a.trim()) + .filter(|a| !a.is_empty()) + .collect(); + if !cleaned.is_empty() { + filter.insert("authors".to_string(), serde_json::json!(cleaned)); + } + } + if let Some(since) = since { + filter.insert("since".to_string(), serde_json::json!(since)); + } + if let Some(until) = until { + filter.insert("until".to_string(), serde_json::json!(until)); + } serde_json::Value::Object(filter) } @@ -161,10 +187,20 @@ pub async fn search_messages( q: String, limit: Option, channel_id: Option, + authors: Option>, + since: Option, + until: Option, state: State<'_, AppState>, ) -> Result { let cap = limit.unwrap_or(20).min(100); - let filter = build_search_messages_filter(&q, cap, channel_id.as_deref()); + let filter = build_search_messages_filter( + &q, + cap, + channel_id.as_deref(), + authors.as_deref(), + since, + until, + ); let events = query_relay(&state, &[filter]).await?; Ok(nostr_convert::search_response_from_events(&events)) diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index 3c08f0f1130..a907a3dff1d 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -80,12 +80,38 @@ fn managed_agent_message_builder_rejects_invalid_mentions() { } #[test] fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() { - let filter = build_search_messages_filter(" pro ", 12, Some("channel-1")); + let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"), None, None, None); assert_eq!(filter["search"], serde_json::json!("pro")); assert_eq!(filter["search_mode"], serde_json::json!("prefix")); assert_eq!(filter["limit"], serde_json::json!(12)); assert_eq!(filter["#h"], serde_json::json!(["channel-1"])); + assert!(filter.get("authors").is_none()); + assert!(filter.get("since").is_none()); + assert!(filter.get("until").is_none()); +} + +#[test] +fn search_messages_filter_emits_operator_fields() { + let authors = + vec!["aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899".to_string()]; + let filter = build_search_messages_filter( + "deploy", + 20, + Some("channel-uuid"), + Some(&authors), + Some(1_700_000_000), + Some(1_700_086_400), + ); + + assert_eq!(filter["search"], serde_json::json!("deploy")); + assert_eq!(filter["#h"], serde_json::json!(["channel-uuid"])); + assert_eq!( + filter["authors"], + serde_json::json!(["aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"]) + ); + assert_eq!(filter["since"], serde_json::json!(1_700_000_000)); + assert_eq!(filter["until"], serde_json::json!(1_700_086_400)); } #[test] diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a048ad24af5..1c89ee4f771 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ mod export_util; mod global_agent_config; mod identity; mod identity_archive; +mod join_policy; mod legacy_storage; mod link_preview; pub(crate) mod media; @@ -78,6 +79,7 @@ pub use engrams::*; pub use global_agent_config::*; pub use identity::*; pub use identity_archive::*; +pub use join_policy::*; pub use legacy_storage::*; pub use link_preview::*; pub use media::*; diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs new file mode 100644 index 00000000000..c00de1c6da1 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -0,0 +1,85 @@ +//! The persona creation command surface, split from `mod.rs` (file-size cap) +//! as the sibling of [`super::update`]. + +use tauri::AppHandle; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + managed_agents::{ + apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, AgentDefinition, + CatalogSource, CreatePersonaRequest, + }, + util::now_iso, +}; + +use super::{pending, retain_persona_pending, trim_optional, trim_required}; + +#[tauri::command] +pub async fn create_persona( + input: CreatePersonaRequest, + app: AppHandle, +) -> Result { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let display_name = trim_required(&input.display_name, "Display name")?; + // System prompt optional: core memory is auto-injected. Empty is valid. + let system_prompt = input.system_prompt.trim().to_string(); + let avatar_url = trim_optional(input.avatar_url); + let runtime = trim_optional(input.runtime); + let model = trim_optional(input.model); + let provider = trim_optional(input.provider); + // Normalized before the store is touched: a coordinate that can't match + // a publication is worse than no coordinate, because it silently + // re-enables the duplicate add it exists to prevent. + let catalog_source = input + .catalog_source + .map(CatalogSource::normalized) + .transpose()?; + let now = now_iso(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); + let name_pool: Vec = input + .name_pool + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + crate::managed_agents::validate_user_env_keys(&input.env_vars)?; + let mut persona = AgentDefinition { + id: Uuid::new_v4().to_string(), + display_name, + avatar_url, + system_prompt, + runtime, + model, + provider, + name_pool, + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source, + env_vars: input.env_vars, + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: now.clone(), + updated_at: now, + }; + apply_persona_behavior(&mut persona, input.behavior)?; + personas.push(persona.clone()); + save_personas(&app, &personas)?; + retain_persona_pending(&app, &state, &persona); + try_regenerate_nest(&app); + Ok(persona) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} 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 316af5f72d0..8ff7cfbd9bd 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -61,8 +61,10 @@ fn make_agent( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs new file mode 100644 index 00000000000..d7ffecef2d6 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -0,0 +1,450 @@ +//! Inbound relay → local store reconciliation for persona/team/managed-agent +//! projections and their NIP-09 tombstones. Extracted from the parent module to +//! keep it under the file-size cap. + +use tauri::{AppHandle, Emitter, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_events::ManagedAgentEventContent, load_personas, persona_events::persona_d_tag, + save_personas, team_events::TeamEventContent, try_regenerate_nest, AgentDefinition, + ManagedAgentRecord, TeamRecord, + }, + util::now_iso, +}; + +#[cfg(test)] +mod inbound_tests; + +/// Apply an inbound kind:30175 persona event from the relay onto the local +/// store. The frontend's live subscription invokes this per event for our own +/// authored coordinate so Device B inherits Device A's edits. +/// +/// Retention is a sync channel that writes INTO `personas.json`, never an +/// authoritative read source — `load_personas` is untouched, so every agent +/// keeps resolving its persona by UUID and keeps its provider keys. +/// +/// MATCH KEY (single source of truth, both directions): an inbound event +/// matches the local record whose `persona_d_tag(record)` equals the event's +/// d-tag. Reusing the same derivation the outbound path uses guarantees the +/// inbound key can never drift from the outbound key — in particular, an +/// in-app persona (`source_team_persona_slug == None`) whose d-tag IS its +/// `id` matches its existing UUID row instead of minting a duplicate. +/// +/// On match: patch ONLY the projected fields; preserve local `id`, `env_vars`, +/// `source_team`, and `created_at`. On no match: insert the parsed record as-is +/// — `persona_from_event` already sets `id = d_tag`, so an in-app persona reuses +/// its d-tag as the id and a re-received event stays idempotent (no duplicate). +/// +/// The retention store decides whether the inbound event wins over a pending +/// local edit (`retain_inbound_event`): `personas.json` is only patched when the +/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with +/// a pending local edit leaves the local record — and its queued publish — +/// untouched. +/// +/// `arrival_relay_url` is the relay the calling subscription is bound to. The +/// retention store this event belongs to is decided by the community that +/// DELIVERED it, not by whichever community happens to be active when the +/// reconcile runs — a workspace switch in flight would otherwise file community +/// A's event into community B's scoped database. An event whose arrival relay is +/// no longer the active scope is dropped: it was already durable in its own +/// community's store when it arrived there, and that community's next boot +/// reconcile refetches it. +#[tauri::command] +pub async fn reconcile_inbound_persona_event( + event_json: String, + arrival_relay_url: String, + app: AppHandle, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, app) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn reconcile_inbound_persona_event_blocking( + event_json: String, + arrival_relay_url: String, + app: AppHandle, +) -> Result<(), String> { + use crate::managed_agents::{ + agent_events::managed_agent_content_from_event, + load_managed_agents, load_teams, + persona_events::persona_from_event, + retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, + save_managed_agents, save_teams, + team_events::team_content_from_event, + }; + use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + use nostr::JsonUtil; + + let state = app.state::(); + let event = parse_verified_inbound_event(&event_json)?; + + // The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5 + // (NIP-09 deletions). d-tags are NOT unique across kinds, so every path + // below dispatches on kind FIRST and only ever touches its own store — a + // cross-kind d-tag collision can never link a team to a persona or agent. + let kind = event.kind.as_u16() as u32; + + // kind:5 deletion: a tombstone removes the local record at the coordinate + // in its `a` tag (`::`). Handled before the + // upsert dispatch because its coordinate and retention key differ. + if kind == KIND_DELETION { + return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state); + } + + if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + return Ok(()); + } + + // The d-tag identifies the record within its kind. Persona derives it from + // the parsed record (`persona_d_tag`); team/agent carry it as the event's + // d-tag directly. The persona is parsed once here and reused in the apply + // branch below — team/agent content is parsed in-branch since their d-tag + // comes from the event tag, not the content. + let inbound_persona = (kind == KIND_PERSONA) + .then(|| persona_from_event(&event)) + .transpose()?; + let d_tag = match &inbound_persona { + Some(persona) => persona_d_tag(persona), + None => event_d_tag(&event)?, + }; + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Resolve inbound vs. any pending local edit before touching the store, in + // the scope the event ARRIVED on. A workspace switch since arrival leaves + // this event to its own community's store — dropping it here is what keeps + // community A's head out of community B's database. + let Some(scope) = crate::managed_agents::retention::arrival_retention_scope( + &app, + &state, + &arrival_relay_url, + )? + else { + return Ok(()); + }; + let conn = open_retention_db(&scope.db_path)?; + let outcome = retain_inbound_event( + &conn, + &RetainedEvent { + kind, + pubkey: event.pubkey.to_hex(), + d_tag: d_tag.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + )?; + if outcome == InboundOutcome::Skipped { + return Ok(()); + } + + match kind { + KIND_PERSONA => { + let mut personas = load_personas(&app)?; + // `inbound_persona` is `Some` for KIND_PERSONA (set above). + apply_inbound_persona( + &mut personas, + inbound_persona.expect("persona parsed above"), + ); + save_personas(&app, &personas)?; + } + KIND_TEAM => { + let mut teams = load_teams(&app)?; + apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); + save_teams(&app, &teams)?; + } + KIND_MANAGED_AGENT => { + let mut agents = load_managed_agents(&app)?; + apply_inbound_managed_agent( + &mut agents, + &d_tag, + managed_agent_content_from_event(&event)?, + ); + save_managed_agents(&app, &agents)?; + } + _ => unreachable!("kind gated above"), + } + try_regenerate_nest(&app); + + // Signal the live UI to refetch agents data — inbound relay events otherwise + // land on disk silently, leaving the Agents tab stale until restart. + let _ = app.emit("agents-data-changed", ()); + + Ok(()) +} + +/// Parse an inbound wire event and enforce the signature gate. Everything +/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, +/// behavioral-quad application), so a forged pubkey must die here — the +/// TS-side owner filter reads the same attacker-controlled field and is no +/// defense. +fn parse_verified_inbound_event(event_json: &str) -> Result { + use nostr::JsonUtil; + let event = nostr::Event::from_json(event_json) + .map_err(|e| format!("failed to parse inbound event: {e}"))?; + event + .verify() + .map_err(|e| format!("inbound event failed signature verification: {e}"))?; + Ok(event) +} + +/// Parse a NIP-09 `a`-tag coordinate `::` into its +/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so +/// the caller no-ops on a tombstone it can't route. +fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { + event.tags.iter().find_map(|tag| { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + if values.first() != Some(&"a") { + return None; + } + let coord = values.get(1)?; + // `::` — d_tag may itself contain ':' so split at + // most twice and keep the remainder as the d_tag. + let mut parts = coord.splitn(3, ':'); + let kind: u32 = parts.next()?.parse().ok()?; + let owner = parts.next()?; + // NIP-09 scoping: only the record's author may tombstone it. The + // signature gate upstream proves `event.pubkey`; requiring the + // coordinate owner to match closes the other half — a validly + // signed kind:5 naming ANOTHER owner's coordinate must no-op. + if owner != event.pubkey.to_hex() { + return None; + } + let d_tag = parts.next()?; + Some((kind, d_tag.to_string())) + }) +} + +/// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the +/// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — +/// arrival-scoped retention resolution under the store lock, then a per-kind +/// store mutation — but removes rather than patches. Unknown/malformed +/// coordinates no-op, as does a tombstone whose arrival community is no longer +/// active. +fn reconcile_inbound_tombstone( + event: &nostr::Event, + arrival_relay_url: &str, + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + use crate::managed_agents::{ + load_managed_agents, load_teams, + retention::{ + open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, + RetainedEvent, + }, + save_managed_agents, save_teams, + }; + use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + use nostr::JsonUtil; + + let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else { + return Ok(()); // no routable coordinate — nothing to delete + }; + if !matches!(target_kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + return Ok(()); // deletion for a kind we don't track locally + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Resolve against the retained tombstone row (keyed by the target + // coordinate, F2c) so a re-received tombstone or one older than a pending + // local edit is a no-op. Scoped to the arrival community, so a workspace + // switch since arrival drops the tombstone instead of retaining it — and + // deleting a record — in the wrong community's store. + let Some(scope) = + crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + else { + return Ok(()); + }; + let conn = open_retention_db(&scope.db_path)?; + let outcome = retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_DELETION, + pubkey: event.pubkey.to_hex(), + d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + )?; + if outcome == InboundOutcome::Skipped { + return Ok(()); + } + + // Remove the local record using the SAME per-kind match rule the apply fns + // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. + match target_kind { + KIND_PERSONA => { + let mut personas = load_personas(app)?; + personas.retain(|record| persona_d_tag(record) != target_d_tag); + save_personas(app, &personas)?; + } + KIND_TEAM => { + let mut teams = load_teams(app)?; + teams.retain(|record| record.id != target_d_tag); + save_teams(app, &teams)?; + } + KIND_MANAGED_AGENT => { + let mut agents = load_managed_agents(app)?; + agents.retain(|record| record.pubkey != target_d_tag); + save_managed_agents(app, &agents)?; + } + _ => unreachable!("target kind gated above"), + } + try_regenerate_nest(app); + + // Refresh the live UI on inbound deletion — a removal is as user-visible as + // an upsert and the Agents tab must drop the tombstoned record without restart. + let _ = app.emit("agents-data-changed", ()); + + Ok(()) +} + +/// Extract the `d` tag value from an event, the match key for team (= team id) +/// and managed-agent (= agent pubkey) inbound reconcile. +fn event_d_tag(event: &nostr::Event) -> Result { + event + .tags + .iter() + .find_map(|tag| { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + (values.first() == Some(&"d")) + .then(|| values.get(1).map(|s| s.to_string())) + .flatten() + }) + .ok_or_else(|| "inbound event missing d-tag".to_string()) +} + +/// Merge a parsed inbound persona into the local set: patch the matching record +/// in place, or push it when none matches. +/// +/// The match key is `persona_d_tag` — the same derivation the outbound path +/// uses — so the inbound and outbound keys can never drift. On match, only the +/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and +/// `created_at` survive. On no match, the parsed record is inserted as-is; since +/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as +/// the id and a re-received event stays idempotent (no duplicate row). +fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { + let d_tag = persona_d_tag(&inbound); + match personas + .iter_mut() + .find(|record| persona_d_tag(record) == d_tag) + { + Some(local) => { + local.display_name = inbound.display_name; + local.avatar_url = inbound.avatar_url; + local.system_prompt = inbound.system_prompt; + local.runtime = inbound.runtime; + local.model = inbound.model; + local.provider = inbound.provider; + local.name_pool = inbound.name_pool; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + local.parallelism = inbound.parallelism; + local.shared = inbound.shared; + local.updated_at = inbound.updated_at; + } + None => personas.push(inbound), + } +} + +/// Merge an inbound kind:30177 managed-agent projection into the local set. +/// +/// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag +/// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the +/// 10 projected fields; every secret (`private_key_nsec`, `auth_tag`, +/// `env_vars`, `backend`), the harness pins (`agent_command`, +/// `agent_command_override`), and all runtime/local fields are preserved +/// untouched. The projection type carries none of them, so they cannot be +/// reached here even if a foreign event tried to inject them. +/// +/// No match is a no-op: managed agents carry device-local secrets and are never +/// minted from a relay event — an agent that does not already exist locally has +/// no secret key to run with, so inserting a secretless shell would be useless +/// and misleading. This diverges from the persona path, which DOES insert on no +/// match (personas are secretless definitions). Flagged in the reconcile docs. +fn apply_inbound_managed_agent( + agents: &mut [ManagedAgentRecord], + d_tag: &str, + inbound: ManagedAgentEventContent, +) { + if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { + local.name = inbound.name; + // Mirror of the slimmed writer (agent_event_content): a + // definition-linked event omits the definition quad because those + // fields resolve through the kind:30175 definition — absent means + // "not carried", never "clear". Definition-less events still carry + // the quad and apply it unconditionally (including clears). + let definition_linked = inbound.persona_id.is_some(); + local.persona_id = inbound.persona_id; + if !definition_linked { + local.system_prompt = inbound.system_prompt; + local.model = inbound.model; + local.provider = inbound.provider; + local.persona_source_version = inbound.persona_source_version; + } + local.parallelism = inbound.parallelism; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + } +} + +/// Merge an inbound kind:30176 team projection into the local set. +/// +/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS +/// the team id — see `build_team_event`). On match, overwrite ONLY the three +/// shared fields (`name`, `description`, `persona_ids`); install-specific local +/// fields (`source_dir`, `is_symlink`, `symlink_target`, `is_builtin`, +/// `version`, `created_at`) are preserved. On no match, insert a fresh record +/// reusing the d-tag as the id so a re-received event stays idempotent — +/// symmetric to the persona path, since a team (like a persona) is a secretless +/// definition that another device may legitimately learn about from the relay. +fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamEventContent) { + match teams.iter_mut().find(|record| record.id == d_tag) { + Some(local) => { + local.name = inbound.name; + local.description = inbound.description; + // `None` means the event came from a client that predates + // always-publish — its true value is unknown, so preserve + // local. Only `Some` (including the explicit-clear variants) + // overwrites. See `TeamEventContent` for the wire rules. + if let Some(instructions) = inbound.instructions { + local.instructions = instructions; + } + if let Some(persona_ids) = inbound.persona_ids { + local.persona_ids = persona_ids; + } + } + None => teams.push(TeamRecord { + id: d_tag, + name: inbound.name, + description: inbound.description, + // Fresh insert has no local value to preserve; `None` from a + // pre-fix client simply means no known value. + instructions: inbound.instructions.unwrap_or_default(), + persona_ids: inbound.persona_ids.unwrap_or_default(), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: now_iso(), + updated_at: now_iso(), + }), + } +} diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs similarity index 99% rename from desktop/src-tauri/src/commands/personas/inbound_tests.rs rename to desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1000e48b70c..1005a83432d 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -20,8 +20,10 @@ fn local_in_app() -> AgentDefinition { name_pool: vec!["Local".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: Some("team-1".to_string()), source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]), respond_to: None, respond_to_allowlist: Vec::new(), @@ -45,8 +47,10 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { name_pool: vec!["Remote".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: Some(d_tag.to_string()), + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -203,8 +207,10 @@ fn local_agent() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 6437d4b1a88..66f7296a251 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -1,16 +1,12 @@ -use tauri::{AppHandle, Emitter, Manager}; -use uuid::Uuid; +use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - agent_events::ManagedAgentEventContent, apply_persona_behavior, current_instance_id, - delete_agent_key, effective_agent_command, load_managed_agents, load_personas, load_teams, - managed_agent_avatar_url, persona_events::persona_d_tag, save_managed_agents, - save_personas, stop_managed_agent_process, sync_managed_agent_processes, - team_events::TeamEventContent, try_regenerate_nest, validate_persona_activation_change, - validate_persona_deletion, AgentDefinition, CreatePersonaRequest, ManagedAgentRecord, - TeamRecord, UpdatePersonaRequest, + current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, + save_managed_agents, save_personas, stop_managed_agent_process, + sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, + validate_persona_deletion, AgentDefinition, ManagedAgentRecord, }, util::now_iso, }; @@ -33,289 +29,35 @@ fn trim_optional(value: Option) -> Option { mod pending; pub(in crate::commands) use pending::retain_persona_pending; pub(super) use pending::tombstone_persona_pending; +mod create; +pub use create::create_persona; +mod sharing; +pub use sharing::set_persona_shared; +pub use sharing::update_persona_and_publish; +mod update; +pub use update::update_persona; +mod inbound; +pub use inbound::reconcile_inbound_persona_event; #[tauri::command] pub async fn list_personas(app: AppHandle) -> Result, String> { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_personas(&app) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -#[tauri::command] -pub async fn create_persona( - input: CreatePersonaRequest, - app: AppHandle, -) -> Result { - use tauri::Manager; - tokio::task::spawn_blocking(move || { - let state = app.state::(); - let display_name = trim_required(&input.display_name, "Display name")?; - // System prompt optional: core memory is auto-injected. Empty is valid. - let system_prompt = input.system_prompt.trim().to_string(); - let avatar_url = trim_optional(input.avatar_url); - let runtime = trim_optional(input.runtime); - let model = trim_optional(input.model); - let provider = trim_optional(input.provider); - let now = now_iso(); let _store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - let name_pool: Vec = input - .name_pool - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - crate::managed_agents::validate_user_env_keys(&input.env_vars)?; - let mut persona = AgentDefinition { - id: Uuid::new_v4().to_string(), - display_name, - avatar_url, - system_prompt, - runtime, - model, - provider, - name_pool, - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: input.env_vars, - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: now.clone(), - updated_at: now, - }; - apply_persona_behavior(&mut persona, input.behavior)?; - personas.push(persona.clone()); - save_personas(&app, &personas)?; - retain_persona_pending(&app, &state, &persona); - try_regenerate_nest(&app); - Ok(persona) + pending::project_active_persona_sharing(&app, &state, &mut personas); + Ok(personas) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } -/// Return value of the `update_persona` command. Uses flatten so all -/// `AgentDefinition` fields appear at the top level of the JSON response — -/// backward-compatible with callers that already destructure a raw persona object. -#[derive(Debug, serde::Serialize)] -pub struct UpdatePersonaResult { - #[serde(flatten)] - persona: AgentDefinition, -} - -/// Propagate a persona definition's display_name rename to linked agent instances. -/// Only instances whose current `name` equals `old_display_name` are updated; -/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name. -/// Updates both `record.name` (relay display name) and `record.display_name`. -/// Returns the pubkeys of the records that were renamed. -fn propagate_persona_name_rename( - records: &mut [ManagedAgentRecord], - persona_id: &str, - old_display_name: &str, - new_display_name: &str, -) -> Vec { - let mut renamed = Vec::new(); - for record in records.iter_mut() { - if record.persona_id.as_deref() != Some(persona_id) { - continue; - } - if record.name != old_display_name { - continue; // pool-named instance — keep its individualised name - } - record.name = new_display_name.to_string(); - record.display_name = Some(new_display_name.to_string()); - renamed.push(record.pubkey.clone()); - } - renamed -} - -#[tauri::command] -pub async fn update_persona( - input: UpdatePersonaRequest, - app: AppHandle, -) -> Result { - use tauri::Manager; - - /// Profile sync params collected under the store lock for async relay publish. - type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option, Option)>; - - // Phase 1: synchronous save (persona record + linked agent avatar updates) - let (result, profile_sync_params) = tokio::task::spawn_blocking({ - let app = app.clone(); - move || -> Result<(AgentDefinition, ProfileSyncParams), String> { - let state = app.state::(); - let display_name = trim_required(&input.display_name, "Display name")?; - let system_prompt = input.system_prompt.clone(); - let avatar_url = trim_optional(input.avatar_url); - let runtime = trim_optional(input.runtime); - let model = trim_optional(input.model); - let provider = trim_optional(input.provider); - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - let persona = personas - .iter_mut() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("agent {} not found", input.id))?; - - // Track what changed so we can propagate to linked agent records. - let avatar_changed = persona.avatar_url != avatar_url; - let name_changed = persona.display_name != display_name; - let old_display_name = persona.display_name.clone(); - - persona.display_name = display_name; - persona.avatar_url = avatar_url; - persona.system_prompt = system_prompt; - persona.runtime = runtime; - persona.model = model; - persona.provider = provider; - persona.name_pool = input - .name_pool - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - persona.env_vars = env_vars; - } - apply_persona_behavior(persona, input.behavior)?; - persona.updated_at = now_iso(); - - let result = persona.clone(); - save_personas(&app, &personas)?; - - retain_persona_pending(&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 { - let mut records = load_managed_agents(&app)?; - let mut params: ProfileSyncParams = Vec::new(); - let mut agents_modified = false; - let workspace_relay = crate::relay::relay_ws_url_with_override(&state); - - // Propagate the display_name rename to instances that still - // carry the old definition display_name (pool-named instances - // keep their individualised name) in one pass; the loop below - // only decides which records need a relay profile sync. - let renamed: Vec = if name_changed { - propagate_persona_name_rename( - &mut records, - &result.id, - &old_display_name, - &result.display_name, - ) - } else { - Vec::new() - }; - - for record in records.iter_mut() { - if record.persona_id.as_deref() != Some(&result.id) { - continue; - } - let mut record_changed = renamed.contains(&record.pubkey); - - if avatar_changed { - // Update the persisted avatar so reconciliation on next - // start agrees with what we're about to publish. - // When the persona avatar is cleared, fall back to the - // command-default icon so the record never stores `None` - // (which reconcile_agent_profile treats as "un-migrated"). - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - std::slice::from_ref(&result), - record.agent_command_override.as_deref(), - ); - record.avatar_url = result - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_cmd)); - record_changed = true; - } - - if record_changed { - agents_modified = true; - if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &workspace_relay, - ); - params.push(( - agent_keys, - relay_url, - record.name.clone(), - record.avatar_url.clone(), - record.auth_tag.clone(), - )); - } - } - } - - if agents_modified { - save_managed_agents(&app, &records)?; - } - - params - } else { - Vec::new() - }; - - Ok((result, sync_params)) - } - }) - .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) - // 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 { - if let Err(e) = crate::relay::sync_managed_agent_profile( - &state, - &relay_url, - &agent_keys, - &display_name, - avatar_url.as_deref(), - auth_tag.as_deref(), - ) - .await - { - eprintln!("buzz-desktop: relay profile sync failed after persona update: {e}"); - } - } - } - - Ok(UpdatePersonaResult { persona: result }) -} - #[cfg(test)] mod delete_cascade_tests; -#[cfg(test)] -mod inbound_tests; -#[cfg(test)] -mod name_propagation_tests; /// Return pubkeys of every managed agent whose definition is the given persona. /// @@ -510,403 +252,6 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { .map_err(|e| format!("spawn_blocking failed: {e}"))? } -/// Apply an inbound kind:30175 persona event from the relay onto the local -/// store. The frontend's live subscription invokes this per event for our own -/// authored coordinate so Device B inherits Device A's edits. -/// -/// Retention is a sync channel that writes INTO `personas.json`, never an -/// authoritative read source — `load_personas` is untouched, so every agent -/// keeps resolving its persona by UUID and keeps its provider keys. -/// -/// MATCH KEY (single source of truth, both directions): an inbound event -/// matches the local record whose `persona_d_tag(record)` equals the event's -/// d-tag. Reusing the same derivation the outbound path uses guarantees the -/// inbound key can never drift from the outbound key — in particular, an -/// in-app persona (`source_team_persona_slug == None`) whose d-tag IS its -/// `id` matches its existing UUID row instead of minting a duplicate. -/// -/// On match: patch ONLY the projected fields; preserve local `id`, `env_vars`, -/// `source_team`, and `created_at`. On no match: insert the parsed record as-is -/// — `persona_from_event` already sets `id = d_tag`, so an in-app persona reuses -/// its d-tag as the id and a re-received event stays idempotent (no duplicate). -/// -/// The retention store decides whether the inbound event wins over a pending -/// local edit (`retain_inbound_event`): `personas.json` is only patched when the -/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with -/// a pending local edit leaves the local record — and its queued publish — -/// untouched. -#[tauri::command] -pub async fn reconcile_inbound_persona_event( - event_json: String, - app: AppHandle, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || reconcile_inbound_persona_event_blocking(event_json, app)) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -fn reconcile_inbound_persona_event_blocking( - event_json: String, - app: AppHandle, -) -> Result<(), String> { - use crate::managed_agents::{ - agent_events::managed_agent_content_from_event, - load_managed_agents, load_teams, managed_agents_base_dir, - persona_events::persona_from_event, - retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, - save_managed_agents, save_teams, - team_events::team_content_from_event, - }; - use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; - use nostr::JsonUtil; - - let state = app.state::(); - let event = parse_verified_inbound_event(&event_json)?; - - // The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5 - // (NIP-09 deletions). d-tags are NOT unique across kinds, so every path - // below dispatches on kind FIRST and only ever touches its own store — a - // cross-kind d-tag collision can never link a team to a persona or agent. - let kind = event.kind.as_u16() as u32; - - // kind:5 deletion: a tombstone removes the local record at the coordinate - // in its `a` tag (`::`). Handled before the - // upsert dispatch because its coordinate and retention key differ. - if kind == KIND_DELETION { - return reconcile_inbound_tombstone(&event, &app, &state); - } - - if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { - return Ok(()); - } - - // The d-tag identifies the record within its kind. Persona derives it from - // the parsed record (`persona_d_tag`); team/agent carry it as the event's - // d-tag directly. The persona is parsed once here and reused in the apply - // branch below — team/agent content is parsed in-branch since their d-tag - // comes from the event tag, not the content. - let inbound_persona = (kind == KIND_PERSONA) - .then(|| persona_from_event(&event)) - .transpose()?; - let d_tag = match &inbound_persona { - Some(persona) => persona_d_tag(persona), - None => event_d_tag(&event)?, - }; - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - - // Resolve inbound vs. any pending local edit before touching the store. - let conn = open_retention_db(&managed_agents_base_dir(&app)?.join("retention.db"))?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind, - pubkey: event.pubkey.to_hex(), - d_tag: d_tag.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); - } - - match kind { - KIND_PERSONA => { - let mut personas = load_personas(&app)?; - // `inbound_persona` is `Some` for KIND_PERSONA (set above). - apply_inbound_persona( - &mut personas, - inbound_persona.expect("persona parsed above"), - ); - save_personas(&app, &personas)?; - } - KIND_TEAM => { - let mut teams = load_teams(&app)?; - apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); - save_teams(&app, &teams)?; - } - KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(&app)?; - apply_inbound_managed_agent( - &mut agents, - &d_tag, - managed_agent_content_from_event(&event)?, - ); - save_managed_agents(&app, &agents)?; - } - _ => unreachable!("kind gated above"), - } - try_regenerate_nest(&app); - - // Signal the live UI to refetch agents data — inbound relay events otherwise - // land on disk silently, leaving the Agents tab stale until restart. - let _ = app.emit("agents-data-changed", ()); - - Ok(()) -} - -/// Parse an inbound wire event and enforce the signature gate. Everything -/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, -/// behavioral-quad application), so a forged pubkey must die here — the -/// TS-side owner filter reads the same attacker-controlled field and is no -/// defense. -fn parse_verified_inbound_event(event_json: &str) -> Result { - use nostr::JsonUtil; - let event = nostr::Event::from_json(event_json) - .map_err(|e| format!("failed to parse inbound event: {e}"))?; - event - .verify() - .map_err(|e| format!("inbound event failed signature verification: {e}"))?; - Ok(event) -} - -/// Parse a NIP-09 `a`-tag coordinate `::` into its -/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so -/// the caller no-ops on a tombstone it can't route. -fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { - event.tags.iter().find_map(|tag| { - let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); - if values.first() != Some(&"a") { - return None; - } - let coord = values.get(1)?; - // `::` — d_tag may itself contain ':' so split at - // most twice and keep the remainder as the d_tag. - let mut parts = coord.splitn(3, ':'); - let kind: u32 = parts.next()?.parse().ok()?; - let owner = parts.next()?; - // NIP-09 scoping: only the record's author may tombstone it. The - // signature gate upstream proves `event.pubkey`; requiring the - // coordinate owner to match closes the other half — a validly - // signed kind:5 naming ANOTHER owner's coordinate must no-op. - if owner != event.pubkey.to_hex() { - return None; - } - let d_tag = parts.next()?; - Some((kind, d_tag.to_string())) - }) -} - -/// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the -/// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — -/// retention resolution under the store lock, then a per-kind store mutation — -/// but removes rather than patches. Unknown/malformed coordinates no-op. -fn reconcile_inbound_tombstone( - event: &nostr::Event, - app: &AppHandle, - state: &AppState, -) -> Result<(), String> { - use crate::managed_agents::{ - load_managed_agents, load_teams, managed_agents_base_dir, - retention::{ - open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, - RetainedEvent, - }, - save_managed_agents, save_teams, - }; - use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; - use nostr::JsonUtil; - - let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else { - return Ok(()); // no routable coordinate — nothing to delete - }; - if !matches!(target_kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { - return Ok(()); // deletion for a kind we don't track locally - } - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - - // Resolve against the retained tombstone row (keyed by the target - // coordinate, F2c) so a re-received tombstone or one older than a pending - // local edit is a no-op. - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind: KIND_DELETION, - pubkey: event.pubkey.to_hex(), - d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); - } - - // Remove the local record using the SAME per-kind match rule the apply fns - // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. - match target_kind { - KIND_PERSONA => { - let mut personas = load_personas(app)?; - personas.retain(|record| persona_d_tag(record) != target_d_tag); - save_personas(app, &personas)?; - } - KIND_TEAM => { - let mut teams = load_teams(app)?; - teams.retain(|record| record.id != target_d_tag); - save_teams(app, &teams)?; - } - KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(app)?; - agents.retain(|record| record.pubkey != target_d_tag); - save_managed_agents(app, &agents)?; - } - _ => unreachable!("target kind gated above"), - } - try_regenerate_nest(app); - - // Refresh the live UI on inbound deletion — a removal is as user-visible as - // an upsert and the Agents tab must drop the tombstoned record without restart. - let _ = app.emit("agents-data-changed", ()); - - Ok(()) -} - -/// Extract the `d` tag value from an event, the match key for team (= team id) -/// and managed-agent (= agent pubkey) inbound reconcile. -fn event_d_tag(event: &nostr::Event) -> Result { - event - .tags - .iter() - .find_map(|tag| { - let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); - (values.first() == Some(&"d")) - .then(|| values.get(1).map(|s| s.to_string())) - .flatten() - }) - .ok_or_else(|| "inbound event missing d-tag".to_string()) -} - -/// Merge a parsed inbound persona into the local set: patch the matching record -/// in place, or push it when none matches. -/// -/// The match key is `persona_d_tag` — the same derivation the outbound path -/// uses — so the inbound and outbound keys can never drift. On match, only the -/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and -/// `created_at` survive. On no match, the parsed record is inserted as-is; since -/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as -/// the id and a re-received event stays idempotent (no duplicate row). -fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { - let d_tag = persona_d_tag(&inbound); - match personas - .iter_mut() - .find(|record| persona_d_tag(record) == d_tag) - { - Some(local) => { - local.display_name = inbound.display_name; - local.avatar_url = inbound.avatar_url; - local.system_prompt = inbound.system_prompt; - local.runtime = inbound.runtime; - local.model = inbound.model; - local.provider = inbound.provider; - local.name_pool = inbound.name_pool; - local.respond_to = inbound.respond_to; - local.respond_to_allowlist = inbound.respond_to_allowlist; - local.parallelism = inbound.parallelism; - local.updated_at = inbound.updated_at; - } - None => personas.push(inbound), - } -} - -/// Merge an inbound kind:30177 managed-agent projection into the local set. -/// -/// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag -/// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the -/// 10 projected fields; every secret (`private_key_nsec`, `auth_tag`, -/// `env_vars`, `backend`), the harness pins (`agent_command`, -/// `agent_command_override`), and all runtime/local fields are preserved -/// untouched. The projection type carries none of them, so they cannot be -/// reached here even if a foreign event tried to inject them. -/// -/// No match is a no-op: managed agents carry device-local secrets and are never -/// minted from a relay event — an agent that does not already exist locally has -/// no secret key to run with, so inserting a secretless shell would be useless -/// and misleading. This diverges from the persona path, which DOES insert on no -/// match (personas are secretless definitions). Flagged in the reconcile docs. -fn apply_inbound_managed_agent( - agents: &mut [ManagedAgentRecord], - d_tag: &str, - inbound: ManagedAgentEventContent, -) { - if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { - local.name = inbound.name; - // Mirror of the slimmed writer (agent_event_content): a - // definition-linked event omits the definition quad because those - // fields resolve through the kind:30175 definition — absent means - // "not carried", never "clear". Definition-less events still carry - // the quad and apply it unconditionally (including clears). - let definition_linked = inbound.persona_id.is_some(); - local.persona_id = inbound.persona_id; - if !definition_linked { - local.system_prompt = inbound.system_prompt; - local.model = inbound.model; - local.provider = inbound.provider; - local.persona_source_version = inbound.persona_source_version; - } - local.parallelism = inbound.parallelism; - local.respond_to = inbound.respond_to; - local.respond_to_allowlist = inbound.respond_to_allowlist; - } -} - -/// Merge an inbound kind:30176 team projection into the local set. -/// -/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS -/// the team id — see `build_team_event`). On match, overwrite ONLY the three -/// shared fields (`name`, `description`, `persona_ids`); install-specific local -/// fields (`source_dir`, `is_symlink`, `symlink_target`, `is_builtin`, -/// `version`, `created_at`) are preserved. On no match, insert a fresh record -/// reusing the d-tag as the id so a re-received event stays idempotent — -/// symmetric to the persona path, since a team (like a persona) is a secretless -/// definition that another device may legitimately learn about from the relay. -fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamEventContent) { - match teams.iter_mut().find(|record| record.id == d_tag) { - Some(local) => { - local.name = inbound.name; - local.description = inbound.description; - // `None` means the event came from a client that predates - // always-publish — its true value is unknown, so preserve - // local. Only `Some` (including the explicit-clear variants) - // overwrites. See `TeamEventContent` for the wire rules. - if let Some(instructions) = inbound.instructions { - local.instructions = instructions; - } - if let Some(persona_ids) = inbound.persona_ids { - local.persona_ids = persona_ids; - } - } - None => teams.push(TeamRecord { - id: d_tag, - name: inbound.name, - description: inbound.description, - // Fresh insert has no local value to preserve; `None` from a - // pre-fix client simply means no known value. - instructions: inbound.instructions.unwrap_or_default(), - persona_ids: inbound.persona_ids.unwrap_or_default(), - is_builtin: false, - source_dir: None, - is_symlink: false, - symlink_target: None, - version: None, - created_at: now_iso(), - updated_at: now_iso(), - }), - } -} - #[tauri::command] pub async fn set_persona_active( id: String, diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 4d887ca39e9..a4003329bca 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -5,7 +5,17 @@ use tauri::AppHandle; use crate::app_state::AppState; -use crate::managed_agents::AgentDefinition; +use crate::managed_agents::{ + retention::{RetainedEvent, RetentionScope}, + AgentDefinition, +}; + +pub(super) struct PreparedPersonaPublication { + pub scope: RetentionScope, + pub event: nostr::Event, + pub retained: RetainedEvent, + pub persona: AgentDefinition, +} /// Retain a freshly authored persona event in the local store, flagged for /// relay sync. Called inside a command's `managed_agents_store_lock`-held body @@ -16,58 +26,162 @@ use crate::managed_agents::AgentDefinition; /// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop, /// which is the sole publisher. Best-effort: a failure here is logged and /// swallowed so a retention hiccup never blocks the disk-authoritative write. +/// The explicit catalog toggle uses [`prepare_persona_publication`] directly +/// so its durable enqueue failure reaches the UI. /// /// Unlike `retain_managed_agent_pending`, this has no projection-equality /// short-circuit: personas have no start/stop runtime churn, so a republish -/// only happens on a genuine create/update/delete user edit (`set_persona_active` -/// does not retain, so the local-only `is_active` toggle never republishes, and -/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The -/// guard is intentionally omitted. +/// only happens on a genuine create/update/delete/share user edit +/// (`set_persona_active` does not retain, so the local-only `is_active` toggle +/// never republishes, while `set_persona_shared` must retain because the tag is +/// relay-authoritative). A byte-identical user-save republish is harmlessly +/// NIP-33-replaced. The guard is intentionally omitted. pub(in crate::commands) fn retain_persona_pending( app: &AppHandle, state: &AppState, persona: &AgentDefinition, ) { + if let Err(e) = prepare_persona_publication(app, state, persona, None) { + eprintln!("buzz-desktop: persona-retain: {e}"); + } +} + +/// Build, sign, and durably retain a persona event in the active relay+owner +/// scope. +/// +/// Ordinary definition writes pass `None` and preserve the scoped head's +/// exact share tag. The explicit share toggle passes `Some(shared)`. Returning +/// the retained event lets that command immediately await relay acceptance +/// without rebuilding or re-signing a different NIP-33 head. +pub(super) fn prepare_persona_publication( + app: &AppHandle, + state: &AppState, + persona: &AgentDefinition, + shared_override: Option, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let (event, retained, persona) = prepare_persona_publication_at( + &scope.db_path, + &scope.owner_keys, + persona, + shared_override, + )?; + Ok(PreparedPersonaPublication { + scope, + event, + retained, + persona, + }) +} + +fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { + use buzz_core_pkg::kind::persona_event_is_shared; + use nostr::JsonUtil; + + row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok()) + .is_some_and(|event| persona_event_is_shared(&event)) +} + +/// Project each persona's catalog visibility from the active relay+owner +/// scope's retained head. +/// +/// Infallible by design. The scope needs `signing_keys()`, which fails for the +/// whole process whenever the identity is lost or the keyring is locked, and a +/// propagated error there would break listing, creating, and updating EVERY +/// agent. Share state is a view projection, so an unresolvable scope degrades +/// to "not shared" — the safe direction: it can under-report visibility but can +/// never present an unshared persona as published. The durable share state +/// lives in the retention head, so nothing is lost: the true value reappears +/// once the identity is signable again. +pub(super) fn project_active_persona_sharing( + app: &AppHandle, + state: &AppState, + personas: &mut [AgentDefinition], +) { + let scope = crate::managed_agents::retention::active_retention_scope(app, state); + project_scoped_persona_sharing(scope, personas); +} + +fn project_scoped_persona_sharing( + scope: Result, + personas: &mut [AgentDefinition], +) { + let projected = scope.and_then(|scope| { + project_persona_sharing_at( + &scope.db_path, + &scope.owner_keys.public_key().to_hex(), + personas, + ) + }); + if let Err(error) = projected { + eprintln!("buzz-desktop: persona-share-projection unavailable, reporting every agent as unshared: {error}"); + for persona in personas { + persona.shared = false; + } + } +} + +fn project_persona_sharing_at( + db_path: &std::path::Path, + owner_pubkey: &str, + personas: &mut [AgentDefinition], +) -> Result<(), String> { + use crate::managed_agents::{ + persona_events::persona_d_tag, + retention::{get_retained_event, open_retention_db}, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + + let conn = open_retention_db(db_path)?; + for persona in personas { + if persona.is_builtin { + persona.shared = false; + continue; + } + let retained = + get_retained_event(&conn, KIND_PERSONA, owner_pubkey, &persona_d_tag(persona))?; + persona.shared = retained_persona_is_shared(retained.as_ref()); + } + Ok(()) +} + +pub(super) fn prepare_persona_publication_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + persona: &AgentDefinition, + shared_override: Option, +) -> Result<(nostr::Event, RetainedEvent, AgentDefinition), String> { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; use buzz_core_pkg::kind::KIND_PERSONA; use nostr::JsonUtil; - let result = (|| -> Result<(), String> { - let d_tag = persona_d_tag(persona); - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - // Monotonic created_at: read the retained head for this coordinate - // and bump past it (NIP-AP step 3) so a same-second edit supersedes. - let prior = - get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)? - .map(|row| row.created_at); - let event = build_persona_event(persona)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona event: {e}"))?; - (keys.public_key().to_hex(), event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_PERSONA, - pubkey, - d_tag, - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-retain: {e}"); - } + let d_tag = persona_d_tag(persona); + let pubkey = keys.public_key().to_hex(); + let conn = open_retention_db(db_path)?; + let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?; + let mut scoped_persona = persona.clone(); + scoped_persona.shared = + shared_override.unwrap_or_else(|| retained_persona_is_shared(existing.as_ref())); + let event = build_persona_event(&scoped_persona)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign persona event: {e}"))?; + let retained = RetainedEvent { + kind: KIND_PERSONA, + pubkey, + d_tag, + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + retain_event(&conn, &retained)?; + Ok((event, retained, scoped_persona)) } /// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both @@ -88,7 +202,6 @@ pub(in crate::commands) fn tombstone_persona_pending( d_tag: &str, ) { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::build_persona_delete, retention::{ delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, @@ -101,15 +214,12 @@ pub(in crate::commands) fn tombstone_persona_pending( const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_persona_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_persona_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; // Purge the persona row first so an unpublished edit can never resurrect // it after the tombstone publishes. delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; @@ -132,3 +242,158 @@ pub(in crate::commands) fn tombstone_persona_pending( eprintln!("buzz-desktop: persona-tombstone: {e}"); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, scoped_retention_db_path, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use std::collections::BTreeMap; + + fn persona() -> AgentDefinition { + AgentDefinition { + id: "catalog-reviewer".to_string(), + display_name: "Catalog Reviewer".to_string(), + avatar_url: None, + system_prompt: "Review the catalog.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-27T00:00:00Z".to_string(), + updated_at: "2026-07-27T00:00:00Z".to_string(), + } + } + + #[test] + fn share_state_and_pending_heads_are_scoped_by_relay_and_owner() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let community_a = scoped_retention_db_path(dir.path(), "wss://a.example", &owner); + let community_b = scoped_retention_db_path(dir.path(), "wss://b.example", &owner); + std::fs::create_dir_all(community_a.parent().unwrap()).unwrap(); + + let (_, _, shared_in_a) = + prepare_persona_publication_at(&community_a, &keys, &persona(), Some(true)).unwrap(); + assert!(shared_in_a.shared); + + let (_, _, unshared_in_b) = + prepare_persona_publication_at(&community_b, &keys, &persona(), None).unwrap(); + assert!(!unshared_in_b.shared); + + let mut edited = persona(); + edited.system_prompt = "Review the latest catalog.".to_string(); + let (_, _, edited_in_a) = + prepare_persona_publication_at(&community_a, &keys, &edited, None).unwrap(); + assert!( + edited_in_a.shared, + "ordinary edits preserve only the active scope's share choice" + ); + + let conn_a = open_retention_db(&community_a).unwrap(); + let conn_b = open_retention_db(&community_b).unwrap(); + assert!(retained_persona_is_shared( + get_retained_event(&conn_a, KIND_PERSONA, &owner, "catalog-reviewer") + .unwrap() + .as_ref() + )); + assert!(!retained_persona_is_shared( + get_retained_event(&conn_b, KIND_PERSONA, &owner, "catalog-reviewer") + .unwrap() + .as_ref() + )); + } + + /// A `shared = true` persona plus the scope that says so. + fn shared_persona_scope(dir: &std::path::Path) -> (RetentionScope, Vec) { + let keys = nostr::Keys::generate(); + let db_path = scoped_retention_db_path(dir, "wss://a.example", &keys.public_key().to_hex()); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap(); + ( + RetentionScope { + db_path, + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }, + vec![persona()], + ) + } + + #[test] + fn test_resolvable_scope_projects_the_retained_share_state() { + let dir = tempfile::tempdir().unwrap(); + let (scope, mut personas) = shared_persona_scope(dir.path()); + + project_scoped_persona_sharing(Ok(scope), &mut personas); + + assert!(personas[0].shared); + } + + #[test] + fn test_recovery_mode_identity_projects_unshared_instead_of_failing() { + let dir = tempfile::tempdir().unwrap(); + let (_scope, mut personas) = shared_persona_scope(dir.path()); + personas[0].shared = true; + + // The real recovery-mode failure: `active_retention_scope` cannot + // resolve a scope without signing keys, which is exactly what + // `identity_lost` / `keyring_locked` withhold. + let state = crate::app_state::build_app_state(); + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + let error = state + .signing_keys() + .expect_err("recovery mode must withhold signing keys"); + + project_scoped_persona_sharing(Err(error), &mut personas); + + assert!( + !personas[0].shared, + "an unresolvable scope degrades to unshared so list/create/update keep working" + ); + } + + #[test] + fn test_unopenable_retention_db_projects_unshared_instead_of_failing() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let mut personas = vec![persona()]; + personas[0].shared = true; + + project_scoped_persona_sharing( + Ok(RetentionScope { + // A directory cannot be opened as the retention database. + db_path: dir.path().to_path_buf(), + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }), + &mut personas, + ); + + assert!(!personas[0].shared); + } + + #[test] + fn explicit_share_enqueue_failure_is_returned() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let error = prepare_persona_publication_at(dir.path(), &keys, &persona(), Some(true)) + .expect_err("a directory cannot be opened as the retention database"); + assert!(error.contains("failed to open retention db")); + } +} diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs new file mode 100644 index 00000000000..914c56252d0 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -0,0 +1,391 @@ +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + load_personas, + retention::{mark_synced, open_retention_db}, + AgentDefinition, + }, +}; + +use super::pending::{prepare_persona_publication, PreparedPersonaPublication}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PersonaSharePublicationStatus { + Published, + Queued, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPersonaSharedResult { + pub persona: AgentDefinition, + pub publication_status: PersonaSharePublicationStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_message: Option, +} + +#[tauri::command] +pub async fn set_persona_shared( + id: String, + shared: bool, + app: AppHandle, +) -> Result { + let prepared = tokio::task::spawn_blocking({ + let app = app.clone(); + move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let personas = load_personas(&app)?; + let persona = personas + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("agent {id} not found"))?; + + if persona.is_builtin { + return Err("Built-in agents cannot be shared to the catalog.".to_string()); + } + + // Strict path: unlike ordinary definition saves, an enqueue failure + // for this privacy-sensitive toggle must reach the command/UI. + prepare_persona_publication(&app, &state, persona, Some(shared)) + } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + let state = app.state::(); + publish_prepared_persona(&state, prepared).await +} + +/// Save a persona edit AND publish its catalog head, returning the same +/// `published | queued` outcome as [`set_persona_shared`]. +/// +/// The "save and publish" affordance in the edit dialog promises the change +/// reaches the catalog on save. Plain `update_persona` only enqueues +/// best-effort, so the UI could not report whether the relay accepted it. This +/// takes the identical input and reuses the strict preparation path, then awaits +/// the relay exactly like the share toggle does — a rejection or an unreachable +/// relay stays durably queued for the flush loop and is reported as `queued`. +#[tauri::command] +pub async fn update_persona_and_publish( + input: crate::managed_agents::UpdatePersonaRequest, + app: AppHandle, +) -> Result { + let (_, prepared) = + super::update::update_persona_with(input, app.clone(), |app, state, persona| { + // Strict path: this command's contract is to report the publication + // outcome, so an enqueue failure must reach the UI rather than being + // logged and swallowed. + prepare_persona_publication(app, state, persona, None) + }) + .await?; + + let state = app.state::(); + publish_prepared_persona(&state, prepared).await +} + +async fn publish_prepared_persona( + state: &AppState, + prepared: PreparedPersonaPublication, +) -> Result { + let api_base_url = crate::relay::relay_http_base_url(&prepared.scope.relay_url); + let publish_result = crate::relay::submit_signed_event_at_with_keys( + &prepared.event, + state, + &api_base_url, + &prepared.scope.owner_keys, + ) + .await; + + match publish_result { + Ok(_) => { + let conn = open_retention_db(&prepared.scope.db_path)?; + mark_synced( + &conn, + prepared.retained.kind, + &prepared.retained.pubkey, + &prepared.retained.d_tag, + prepared.retained.created_at, + &prepared.retained.content, + )?; + Ok(SetPersonaSharedResult { + persona: prepared.persona, + publication_status: PersonaSharePublicationStatus::Published, + relay_message: None, + }) + } + Err(error) => Ok(SetPersonaSharedResult { + persona: prepared.persona, + publication_status: PersonaSharePublicationStatus::Queued, + relay_message: Some(error), + }), + } +} + +#[cfg(all(test, not(target_os = "windows")))] +mod tests { + use super::*; + use crate::{ + app_state::build_app_state, + commands::personas::pending::prepare_persona_publication_at, + managed_agents::{ + retention::{get_retained_event, open_retention_db, RetentionScope}, + AgentDefinition, + }, + }; + use std::collections::BTreeMap; + + fn persona() -> AgentDefinition { + AgentDefinition { + id: "catalog-reviewer".to_string(), + display_name: "Catalog Reviewer".to_string(), + avatar_url: None, + system_prompt: "Review the catalog.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-27T00:00:00Z".to_string(), + updated_at: "2026-07-27T00:00:00Z".to_string(), + } + } + + async fn spawn_relay(accepted: bool) -> String { + use axum::{routing::post, Router}; + + let app = Router::new().route( + "/events", + post(move |body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": accepted, + "message": if accepted { "" } else { "policy rejection" } + }) + .to_string() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") + } + + fn prepared( + db_path: &std::path::Path, + relay_url: String, + keys: nostr::Keys, + shared_override: Option, + ) -> PreparedPersonaPublication { + let (event, retained, persona) = + prepare_persona_publication_at(db_path, &keys, &persona(), shared_override).unwrap(); + PreparedPersonaPublication { + scope: RetentionScope { + db_path: db_path.to_path_buf(), + relay_url, + owner_keys: keys, + }, + event, + retained, + persona, + } + } + + #[tokio::test] + async fn relay_rejection_stays_durably_queued() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(false).await, keys, Some(true)); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.contains("relay rejected event"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn unavailable_relay_stays_durably_queued() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, relay_url, keys, Some(true)); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.starts_with("relay unreachable:"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn relay_acceptance_marks_the_scoped_head_synced() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(true).await, keys, Some(true)); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Published + ); + assert!( + !get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + /// `update_persona_and_publish` differs from the share toggle in one way: + /// it passes no share override, so the edit must keep whatever the scoped + /// head already says, and it reports the relay outcome to the caller. + #[tokio::test] + async fn test_update_and_publish_acceptance_publishes_the_edit_at_the_current_share_state() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + // The persona is already shared in this scope. + prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap(); + let prepared = prepared(&db_path, spawn_relay(true).await, keys, None); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Published + ); + assert!( + result.persona.shared, + "an ordinary edit must not silently unshare the persona" + ); + assert!( + !get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn test_update_and_publish_relay_rejection_reports_queued_not_failure() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap(); + let prepared = prepared(&db_path, spawn_relay(false).await, keys, None); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.contains("relay rejected event"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync, + "the edit stays queued for the flush loop" + ); + } + + /// The save path swallows enqueue failures (`retain_persona_pending` logs + /// them). This command promises a publication outcome, so the strict + /// preparation it uses must surface the failure instead. + #[tokio::test] + async fn test_update_and_publish_enqueue_failure_is_returned() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + + let error = prepare_persona_publication_at(dir.path(), &keys, &persona(), None) + .expect_err("a directory cannot be opened as the retention database"); + + assert!(error.contains("failed to open retention db")); + } +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e4d8a5d1bc9..583296dac0c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -164,6 +164,33 @@ fn parse_format_is_png(s: &str) -> Result { } } +fn materialize_portable_runtime_defaults( + record: &mut ManagedAgentRecord, + global: &crate::managed_agents::GlobalAgentConfig, +) { + if record + .model + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + record.model = global.model.clone(); + } + if record + .provider + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + record.provider = global.provider.clone(); + } + if record + .runtime + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + record.runtime = global.preferred_runtime.clone(); + } +} + /// Shared production encoding path. /// /// Resolves the agent definition, validates inputs, fetches optional memory, @@ -196,6 +223,13 @@ pub(crate) async fn materialize_snapshot_bytes( let definitions = load_agent_definitions(&app)?; 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; + // 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 + // for a different recipient setup. + let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + materialize_portable_runtime_defaults(&mut def_record, &global); let memory_pubkey = if memory_level != MemoryLevel::None { let mpk = memory_source_pubkey.as_deref().unwrap_or(""); @@ -400,6 +434,8 @@ pub async fn encode_agent_snapshot_for_send( }) } +#[cfg(test)] +mod fidelity_tests; #[cfg(test)] mod tests; @@ -432,6 +468,7 @@ mod png_body_tests { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition { name: "Agent".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs new file mode 100644 index 00000000000..00a14573938 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -0,0 +1,210 @@ +use super::import::decode_snapshot_from_bytes; +use super::*; +use crate::managed_agents::{ + agent_snapshot::{ + AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotProfile, + FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }, + BackendKind, ManagedAgentRecord, RespondTo, +}; +use std::collections::BTreeMap; + +fn make_definition(slug: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + slug: Some(slug.to_string()), + name: slug.to_string(), + display_name: None, + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: false, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + } +} + +/// Build a minimal valid AgentSnapshot for import tests. +fn make_snapshot( + memory_level: MemoryLevel, + entries: Vec, +) -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Test Agent".to_string(), + source_is_builtin: false, + system_prompt: Some("You are helpful.".to_string()), + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test Agent".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: memory_level, + entries, + }, + } +} + +// ── Portable effective configuration ───────────────────────────────────── + +#[test] +fn inherited_runtime_provider_and_model_are_materialized_for_export() { + let mut record = make_definition("wren"); + let global = crate::managed_agents::GlobalAgentConfig { + preferred_runtime: Some("goose".to_string()), + provider: Some("databricks_v2".to_string()), + model: Some("databricks-gpt-5-6-sol".to_string()), + ..Default::default() + }; + + materialize_portable_runtime_defaults(&mut record, &global); + + assert_eq!(record.runtime.as_deref(), Some("goose")); + assert_eq!(record.provider.as_deref(), Some("databricks_v2")); + assert_eq!(record.model.as_deref(), Some("databricks-gpt-5-6-sol")); +} + +#[test] +fn explicit_runtime_provider_and_model_win_over_global_defaults() { + let mut record = make_definition("wren"); + record.runtime = Some("claude".to_string()); + record.provider = Some("anthropic".to_string()); + record.model = Some("claude-opus-5".to_string()); + let global = crate::managed_agents::GlobalAgentConfig { + preferred_runtime: Some("goose".to_string()), + provider: Some("databricks_v2".to_string()), + model: Some("databricks-gpt-5-6-sol".to_string()), + ..Default::default() + }; + + materialize_portable_runtime_defaults(&mut record, &global); + + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!(record.provider.as_deref(), Some("anthropic")); + assert_eq!(record.model.as_deref(), Some("claude-opus-5")); +} + +/// PNG image-body avatar overrides manifest avatar fields and all definition +/// config survives the exact production decoder. +#[test] +fn import_png_body_avatar_and_full_model_round_trip() { + use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png}; + + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.runtime = Some("goose".to_string()); + snapshot.definition.model = Some("databricks-gpt-5-6-sol".to_string()); + snapshot.definition.provider = Some("databricks_v2".to_string()); + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = Some("https://sender.invalid/avatar.png".to_string()); + + let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 3, + image::Rgba([23, 91, 177, 255]), + )); + let mut avatar_png = std::io::Cursor::new(Vec::new()); + avatar + .write_to(&mut avatar_png, image::ImageFormat::Png) + .unwrap(); + let png_bytes = encode_snapshot_png(&snapshot, Some(avatar_png.get_ref())).unwrap(); + + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + assert_eq!(decoded.definition.runtime.as_deref(), Some("goose")); + assert_eq!( + decoded.definition.model.as_deref(), + Some("databricks-gpt-5-6-sol") + ); + assert_eq!( + decoded.definition.provider.as_deref(), + Some("databricks_v2") + ); + assert_eq!( + decoded.profile.avatar_url.as_deref(), + Some("https://sender.invalid/avatar.png") + ); + + let avatar_data_url = decoded + .profile + .avatar_data_url + .as_deref() + .expect("PNG image body must become the effective portable avatar"); + let avatar_bytes = decode_avatar_data_url(avatar_data_url).unwrap(); + let imported_avatar = image::load_from_memory(&avatar_bytes).unwrap(); + assert_eq!((imported_avatar.width(), imported_avatar.height()), (4, 3)); + assert_eq!( + imported_avatar.to_rgba8().get_pixel(0, 0).0, + [23, 91, 177, 255] + ); +} + +/// The transparent 1×1 no-avatar card must not override a manifest fallback. +#[test] +fn import_png_placeholder_keeps_manifest_avatar_fallback() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string()); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + assert!(decoded.profile.avatar_data_url.is_none()); + assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index ac5c0eace6b..d23efe7730e 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,7 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel}, + agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel}, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -50,6 +50,13 @@ pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), Stri pub struct AgentSnapshotImportPreview { /// Agent display name from the snapshot. pub display_name: String, + /// Whether the exported source definition was built in. This is display + /// metadata only; confirmed imports are always independent custom agents. + pub is_builtin: bool, + /// Preferred model from the exported definition. + pub model: Option, + /// Preferred runtime from the exported definition. + pub runtime: Option, /// System prompt, if any. pub system_prompt: Option, /// Effective avatar: data URL if present, otherwise the source URL fallback. @@ -213,7 +220,15 @@ pub(crate) fn decode_snapshot_from_bytes( file_bytes.len() / (1024 * 1024) )); } - let snapshot = decode_snapshot_png(file_bytes)?; + let mut snapshot = decode_snapshot_png(file_bytes)?; + // The PNG image body is the portable avatar. It deliberately wins over + // manifest avatar fields, whose URL may only be reachable by the + // sender. A 1×1 export placeholder leaves the manifest fallback intact. + if let Some(avatar_data_url) = + crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)? + { + snapshot.profile.avatar_data_url = Some(avatar_data_url); + } if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { return Err( "Snapshot is malformed: memory.level is 'none' but entries are present." @@ -241,6 +256,24 @@ pub(crate) fn decode_snapshot_from_bytes( Ok(snapshot) } +async fn materialize_import_avatar( + avatar_data_url: Option<&str>, + avatar_url: Option<&str>, + upload: F, +) -> Result, String> +where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future>, +{ + let Some(avatar_data_url) = avatar_data_url else { + return Ok(avatar_url.map(str::to_string)); + }; + let avatar_bytes = + crate::managed_agents::agent_snapshot::decode_avatar_data_url(avatar_data_url) + .ok_or_else(|| "Snapshot avatar data is malformed.".to_string())?; + upload(avatar_bytes).await.map(Some) +} + // ── `preview_agent_snapshot_import` ────────────────────────────────────────── /// Decode and validate a snapshot file, returning a preview for the @@ -262,32 +295,41 @@ pub async fn preview_agent_snapshot_import( reject_legacy_persona_filename(&file_name)?; let snapshot = decode_snapshot_from_bytes(&file_bytes)?; - let memory_level = match snapshot.memory.level { - MemoryLevel::None => "none", - MemoryLevel::Core => "core", - MemoryLevel::Everything => "everything", - } - .to_string(); - - Ok(AgentSnapshotImportPreview { - display_name: snapshot.profile.display_name.clone(), - system_prompt: snapshot.definition.system_prompt.clone(), - // Effective avatar: data URL wins; URL fallback if no data URL. - avatar_url: snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()), - memory_level, - memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), - has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), - }) + Ok(build_agent_snapshot_import_preview(&snapshot)) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } +pub(crate) fn build_agent_snapshot_import_preview( + snapshot: &AgentSnapshot, +) -> AgentSnapshotImportPreview { + let memory_level = match snapshot.memory.level { + MemoryLevel::None => "none", + MemoryLevel::Core => "core", + MemoryLevel::Everything => "everything", + } + .to_string(); + + AgentSnapshotImportPreview { + display_name: snapshot.profile.display_name.clone(), + is_builtin: snapshot.definition.source_is_builtin, + model: snapshot.definition.model.clone(), + runtime: snapshot.definition.runtime.clone(), + system_prompt: snapshot.definition.system_prompt.clone(), + // Effective avatar: data URL wins; URL fallback if no data URL. + avatar_url: snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()), + memory_level, + memory_entry_count: snapshot.memory.entries.len(), + source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), + has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), + } +} + // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── /// Import a `buzz-agent-snapshot v1` file as a brand-new agent. @@ -330,12 +372,21 @@ pub async fn confirm_agent_snapshot_import( )?; let minted_parallelism = minted.parallelism; - // Effective avatar: data URL wins; URL fallback when data URL is absent. - let effective_avatar: Option = snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()); + // Profile metadata must contain a hosted URL. Inline avatar data can be far + // larger than the relay's kind:0 content limit, so upload imported pixels + // before minting or persisting the new agent. Failing here keeps import + // atomic instead of creating an agent whose profile can never publish. + let effective_avatar = materialize_import_avatar( + snapshot.profile.avatar_data_url.as_deref(), + snapshot.profile.avatar_url.as_deref(), + |avatar_bytes| async { + crate::commands::media::upload_image_bytes(avatar_bytes, &state) + .await + .map(|descriptor| descriptor.url) + .map_err(|error| format!("Could not upload the imported avatar: {error}")) + }, + ) + .await?; // Wire-format string for the persona definition's respond_to field. // Omit when it is the default (owner-only) to keep definitions clean. @@ -408,8 +459,10 @@ pub async fn confirm_agent_snapshot_import( name_pool: snapshot.definition.name_pool.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: respond_to_wire.clone(), respond_to_allowlist: minted.respond_to_allowlist.clone(), @@ -476,8 +529,10 @@ pub async fn confirm_agent_snapshot_import( respond_to_allowlist: minted.respond_to_allowlist.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, @@ -585,7 +640,6 @@ pub async fn confirm_agent_snapshot_import( fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; @@ -593,11 +647,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.signing_keys()?; + let keys = &scope.owner_keys; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -606,7 +661,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent } let event = build_agent_event(record)? .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|e| format!("failed to sign agent event: {e}"))?; (owner_pubkey, event) }; @@ -683,3 +738,114 @@ async fn submit_engram_event( } Ok(()) } + +#[cfg(test)] +mod import_avatar_tests { + use super::materialize_import_avatar; + use std::cell::Cell; + + #[tokio::test] + async fn inline_avatar_is_uploaded_and_replaced_with_hosted_url() { + let uploaded = Cell::new(false); + let result = materialize_import_avatar( + Some("data:image/png;base64,iVBORw0KGgo="), + Some("https://sender.invalid/avatar.png"), + |bytes| { + uploaded.set(true); + async move { + assert_eq!(bytes, b"\x89PNG\r\n\x1a\n"); + Ok("https://relay.example/media/avatar.png".to_string()) + } + }, + ) + .await + .unwrap(); + + assert!(uploaded.get()); + assert_eq!( + result.as_deref(), + Some("https://relay.example/media/avatar.png") + ); + } + + #[tokio::test] + async fn hosted_avatar_skips_upload() { + let result = + materialize_import_avatar(None, Some("https://sender.example/avatar.png"), |_| async { + panic!("hosted avatars must not be uploaded") + }) + .await + .unwrap(); + + assert_eq!(result.as_deref(), Some("https://sender.example/avatar.png")); + } + + #[tokio::test] + async fn relay_sized_inline_avatar_becomes_bounded_signed_profile() { + use base64::{engine::general_purpose::STANDARD, Engine}; + use image::ImageEncoder; + use nostr::JsonUtil; + + let mut pixels = vec![0_u8; 512 * 512 * 4]; + let mut seed = 0x1234_5678_u32; + for byte in &mut pixels { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + *byte = seed as u8; + } + let mut source = Vec::new(); + image::codecs::png::PngEncoder::new(&mut source) + .write_image(&pixels, 512, 512, image::ExtendedColorType::Rgba8) + .unwrap(); + assert!(source.len() > 256 * 1024); + let data_url = format!("data:image/png;base64,{}", STANDARD.encode(&source)); + assert!(data_url.len() > 256 * 1024); + + let avatar = materialize_import_avatar(Some(&data_url), None, |bytes| async move { + let mime = crate::commands::media::detect_and_validate_mime(&bytes)?; + assert_eq!(mime, "image/png"); + let sanitized = crate::commands::media::sanitize_image_for_upload(bytes, &mime)?; + image::load_from_memory(&sanitized).map_err(|error| error.to_string())?; + Ok("https://relay.example/media/avatar.png".to_string()) + }) + .await + .unwrap() + .unwrap(); + + let event = + crate::events::build_profile(Some("Imported agent"), None, Some(&avatar), None, None) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + assert!(event.content.len() < 64 * 1024); + assert!(!event.content.contains("data:image/")); + assert!(event + .content + .contains("https://relay.example/media/avatar.png")); + assert!(event.as_json().len() < 256 * 1024); + } + + #[tokio::test] + async fn upload_failure_aborts_avatar_materialization() { + let result = materialize_import_avatar( + Some("data:image/png;base64,iVBORw0KGgo="), + None, + |_| async { Err("relay upload failed".to_string()) }, + ) + .await; + + assert_eq!(result.unwrap_err(), "relay upload failed"); + } + + #[tokio::test] + async fn malformed_inline_avatar_fails_before_upload() { + let result = + materialize_import_avatar(Some("data:image/png;base64,not-base64!"), None, |_| async { + panic!("malformed avatars must not be uploaded") + }) + .await; + + assert_eq!(result.unwrap_err(), "Snapshot avatar data is malformed."); + } +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b6e..42893102807 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -1,6 +1,7 @@ use super::import::{ - decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, - AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + build_agent_snapshot_import_preview, decode_snapshot_from_bytes, + reject_legacy_persona_filename, resolve_snapshot_import_behavior, AgentSnapshotImportResult, + MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; use super::*; use crate::managed_agents::{ @@ -64,8 +65,10 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { name_pool: vec![], is_builtin: false, is_active: false, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, @@ -94,6 +97,7 @@ fn make_snapshot( version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "Test Agent".to_string(), + source_is_builtin: false, system_prompt: Some("You are helpful.".to_string()), runtime: None, model: None, @@ -551,6 +555,22 @@ fn import_preview_flags_non_empty_source_allowlist() { ); } +#[test] +fn import_preview_includes_exported_definition_metadata() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.source_is_builtin = true; + snapshot.definition.model = Some("claude-opus-4-5".to_string()); + snapshot.definition.runtime = Some("goose".to_string()); + let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + + let preview = build_agent_snapshot_import_preview(&decoded); + + assert!(preview.is_builtin); + assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); + assert_eq!(preview.runtime.as_deref(), Some("goose")); +} + // ── Import: resolve_snapshot_import_behavior — the production selection path // // All tests below call `resolve_snapshot_import_behavior` directly. This is @@ -614,6 +634,14 @@ fn import_non_allowlist_mode_preserved_when_keep_false() { ); } +#[test] +fn import_catalog_owner_only_without_allowlist_succeeds() { + let minted = resolve_snapshot_import_behavior(Some("owner-only"), &[], None, false).unwrap(); + + assert_eq!(minted.respond_to, RespondTo::OwnerOnly); + assert!(minted.respond_to_allowlist.is_empty()); +} + /// Non-allowlist mode with a non-empty list and keep=true: preserve mode + list. /// The toggle WAS shown (list is non-empty) so keep_allowlist applies. #[test] diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs new file mode 100644 index 00000000000..ed2472d54ea --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -0,0 +1,252 @@ +//! The persona edit command surface: `update_persona` (best-effort enqueue) +//! and the `update_persona_with` seam that `update_persona_and_publish` reuses +//! to await relay acceptance for the same save. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas, + managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest, + AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, + }, + util::now_iso, +}; + +use super::{pending, retain_persona_pending, trim_optional, trim_required}; + +#[cfg(test)] +mod name_propagation_tests; + +/// Return value of the `update_persona` command. Uses flatten so all +/// `AgentDefinition` fields appear at the top level of the JSON response — +/// backward-compatible with callers that already destructure a raw persona object. +#[derive(Debug, serde::Serialize)] +pub struct UpdatePersonaResult { + #[serde(flatten)] + persona: AgentDefinition, +} + +/// Propagate a persona definition's display_name rename to linked agent instances. +/// Only instances whose current `name` equals `old_display_name` are updated; +/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name. +/// Updates both `record.name` (relay display name) and `record.display_name`. +/// Returns the pubkeys of the records that were renamed. +fn propagate_persona_name_rename( + records: &mut [ManagedAgentRecord], + persona_id: &str, + old_display_name: &str, + new_display_name: &str, +) -> Vec { + let mut renamed = Vec::new(); + for record in records.iter_mut() { + if record.persona_id.as_deref() != Some(persona_id) { + continue; + } + if record.name != old_display_name { + continue; // pool-named instance — keep its individualised name + } + record.name = new_display_name.to_string(); + record.display_name = Some(new_display_name.to_string()); + renamed.push(record.pubkey.clone()); + } + renamed +} + +/// Profile sync params collected under the store lock for async relay publish. +type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option, Option)>; + +#[tauri::command] +pub async fn update_persona( + input: UpdatePersonaRequest, + app: AppHandle, +) -> Result { + let (persona, ()) = update_persona_with(input, app, |app, state, persona| { + retain_persona_pending(app, state, persona); + Ok(()) + }) + .await?; + Ok(UpdatePersonaResult { persona }) +} + +/// Save an edited persona, hand the saved record to `retain` while the store +/// lock is still held, then sync the relay profiles of linked agent instances. +/// +/// `retain` is the only difference between the two update commands: +/// [`update_persona`] enqueues best-effort, while +/// [`sharing::update_persona_and_publish`] prepares a strict publication and +/// returns the event so the caller can await relay acceptance. +pub(super) async fn update_persona_with( + input: UpdatePersonaRequest, + app: AppHandle, + retain: impl FnOnce(&AppHandle, &AppState, &AgentDefinition) -> Result + Send + 'static, +) -> Result<(AgentDefinition, R), String> { + use tauri::Manager; + + // Phase 1: synchronous save (persona record + linked agent avatar updates) + let (result, retained, profile_sync_params) = tokio::task::spawn_blocking({ + let app = app.clone(); + move || -> Result<(AgentDefinition, R, ProfileSyncParams), String> { + let state = app.state::(); + let display_name = trim_required(&input.display_name, "Display name")?; + let system_prompt = input.system_prompt.clone(); + let avatar_url = trim_optional(input.avatar_url); + let runtime = trim_optional(input.runtime); + let model = trim_optional(input.model); + let provider = trim_optional(input.provider); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); + let persona = personas + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("agent {} not found", input.id))?; + + // Track what changed so we can propagate to linked agent records. + let avatar_changed = persona.avatar_url != avatar_url; + let name_changed = persona.display_name != display_name; + let old_display_name = persona.display_name.clone(); + + persona.display_name = display_name; + persona.avatar_url = avatar_url; + persona.system_prompt = system_prompt; + persona.runtime = runtime; + persona.model = model; + persona.provider = provider; + persona.name_pool = input + .name_pool + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if let Some(env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(&env_vars)?; + persona.env_vars = env_vars; + } + apply_persona_behavior(persona, input.behavior)?; + persona.updated_at = now_iso(); + + let result = persona.clone(); + save_personas(&app, &personas)?; + + 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 { + let mut records = load_managed_agents(&app)?; + let mut params: ProfileSyncParams = Vec::new(); + let mut agents_modified = false; + let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + + // Propagate the display_name rename to instances that still + // carry the old definition display_name (pool-named instances + // keep their individualised name) in one pass; the loop below + // only decides which records need a relay profile sync. + let renamed: Vec = if name_changed { + propagate_persona_name_rename( + &mut records, + &result.id, + &old_display_name, + &result.display_name, + ) + } else { + Vec::new() + }; + + for record in records.iter_mut() { + if record.persona_id.as_deref() != Some(&result.id) { + continue; + } + let mut record_changed = renamed.contains(&record.pubkey); + + if avatar_changed { + // Update the persisted avatar so reconciliation on next + // start agrees with what we're about to publish. + // When the persona avatar is cleared, fall back to the + // command-default icon so the record never stores `None` + // (which reconcile_agent_profile treats as "un-migrated"). + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(&result), + record.agent_command_override.as_deref(), + ); + record.avatar_url = result + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + record_changed = true; + } + + if record_changed { + agents_modified = true; + if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &workspace_relay, + ); + params.push(( + agent_keys, + relay_url, + record.name.clone(), + record.avatar_url.clone(), + record.auth_tag.clone(), + )); + } + } + } + + if agents_modified { + save_managed_agents(&app, &records)?; + // Keep retained kind:30177 identity records in lockstep with + // the rename (#2423): `record.name` is part of the published + // identity projection, so skipping this strands the relay on + // the stale name→pubkey binding until the next boot reconcile. + // Avatar-only edits are excluded — the avatar is not in the + // projection, so retaining would be a guaranteed no-op. + for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { + crate::commands::agents::retain_managed_agent_pending(&app, &state, record); + } + } + + params + } else { + Vec::new() + }; + + Ok((result, retained, sync_params)) + } + }) + .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) + // 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 { + if let Err(e) = crate::relay::sync_managed_agent_profile( + &state, + &relay_url, + &agent_keys, + &display_name, + avatar_url.as_deref(), + auth_tag.as_deref(), + ) + .await + { + eprintln!("buzz-desktop: relay profile sync failed after persona update: {e}"); + } + } + } + + Ok((result, retained)) +} diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs similarity index 99% rename from desktop/src-tauri/src/commands/personas/name_propagation_tests.rs rename to desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index ba855ccbd64..c60215ae4dd 100644 --- a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -50,8 +50,10 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index 186c1a1cf6c..e4a8ad7b410 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -173,12 +173,23 @@ fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig, needs_credent return apply_git_config(command, &entries); }; command.env("NOSTR_PRIVATE_KEY", &auth.nsec); - entries.push(("credential.helper", cred_helper.display().to_string())); + entries.push(( + "credential.helper", + credential_helper_config_value(cred_helper), + )); entries.push(("credential.useHttpPath", "true".to_string())); } apply_git_config(command, &entries); } +/// Format a path for git `credential.helper`. +/// +/// Git for Windows invokes helpers via MinGW bash, which treats `\` as +/// escapes. Forward slashes work on every platform git supports. +fn credential_helper_config_value(path: &std::path::Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + fn apply_git_config(command: &mut Command, entries: &[(&str, String)]) { command.env("GIT_CONFIG_COUNT", entries.len().to_string()); for (index, (key, value)) in entries.iter().enumerate() { @@ -316,10 +327,20 @@ fn validate_clone_url_against_relay(clone_url: &str, relay_base: &str) -> Result #[cfg(test)] mod tests { use super::{ - clean_branch, clean_target_ref, git_needs_credentials, git_subcommand, validate_clone_url, - validate_clone_url_against_relay, + clean_branch, clean_target_ref, credential_helper_config_value, git_needs_credentials, + git_subcommand, validate_clone_url, validate_clone_url_against_relay, }; + #[test] + fn credential_helper_config_value_uses_forward_slashes() { + let path = + std::path::PathBuf::from(r"C:\Users\x\AppData\Local\Buzz\git-credential-nostr.exe"); + assert_eq!( + credential_helper_config_value(&path), + "C:/Users/x/AppData/Local/Buzz/git-credential-nostr.exe", + ); + } + #[test] fn git_subcommand_skips_global_config_options() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 0476be79a99..91a0126f582 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -129,8 +129,10 @@ fn definition_from_snapshot( name_pool: member.definition.name_pool.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to, respond_to_allowlist: behavior.respond_to_allowlist, @@ -599,8 +601,10 @@ pub async fn confirm_team_snapshot_import( respond_to_allowlist: definition.respond_to_allowlist.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, @@ -846,7 +850,6 @@ pub async fn confirm_team_snapshot_import( fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; @@ -854,11 +857,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.signing_keys()?; + let keys = &scope.owner_keys; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -867,7 +871,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent } let event = build_agent_event(record)? .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|e| format!("failed to sign agent event: {e}"))?; (owner_pubkey, event) }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc61830d..06164113079 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -13,6 +13,7 @@ fn member(name: &str) -> AgentSnapshot { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: AgentSnapshotDefinition { name: name.to_string(), + source_is_builtin: false, system_prompt: Some(format!("{name} prompt")), runtime: Some("goose".to_string()), model: None, @@ -64,8 +65,10 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -84,8 +87,10 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -145,8 +150,10 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -214,8 +221,10 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { respond_to_allowlist: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ea9a6a49582..4377ddaa434 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -39,7 +39,6 @@ fn trim_optional(value: Option) -> Option { /// happens on an actual user edit. The guard is intentionally omitted. pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, team_events::build_team_event, @@ -48,19 +47,16 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - // Monotonic created_at: bump past the retained head (NIP-AP step 3). - let prior = - get_retained_event(&conn, KIND_TEAM, &pubkey, &team.id)?.map(|row| row.created_at); - let event = build_team_event(team)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign team event: {e}"))?; - (pubkey, event) - }; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + // Monotonic created_at: bump past the retained head (NIP-AP step 3). + let prior = + get_retained_event(&conn, KIND_TEAM, &pubkey, &team.id)?.map(|row| row.created_at); + let event = build_team_event(team)? + .custom_created_at(monotonic_created_at(prior)) + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign team event: {e}"))?; retain_event( &conn, &RetainedEvent { @@ -90,7 +86,6 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team /// disk-authoritative delete. fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { use crate::managed_agents::{ - managed_agents_base_dir, retention::{ delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, RetainedEvent, @@ -103,15 +98,12 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_team_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign team tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_team_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign team tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; delete_retained_event(&conn, KIND_TEAM, &pubkey, d_tag)?; retain_event( &conn, diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 561e9019987..731a99d9d9b 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -10,6 +10,31 @@ use crate::managed_agents::{ }; use crate::relay; +/// Adopt the pre-scoping global retention database's pending rows into `scope`. +/// +/// Best-effort: a failure is logged and the boot proceeds. The migration's own +/// crash-safety guards make the next launch retry safely, and blocking the +/// workspace apply on it would be worse than a delayed publish. +fn migrate_legacy_retention_into( + app: &AppHandle, + scope: &crate::managed_agents::retention::RetentionScope, +) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + match crate::managed_agents::retention::migrate_legacy_retention_db( + &base_dir, + &scope.db_path, + &scope.owner_keys.public_key().to_hex(), + ) { + Ok(0) => {} + Ok(copied) => { + eprintln!("buzz-desktop: adopted {copied} legacy retained event(s) into this community") + } + Err(error) => eprintln!("buzz-desktop: legacy retention migration failed: {error}"), + } +} + #[derive(Deserialize)] struct RelayInfoIcon { #[serde(default)] @@ -187,6 +212,27 @@ pub async fn apply_workspace( .map_err(|e| format!("spawn_blocking failed: {e}"))??; let state = restore_app.state::(); + // Backfill this exact relay+owner scope only after the workspace has been + // applied. Running at process boot would target the fallback relay and + // collapse every community into one pending-event store. + match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) { + Ok(scope) => { + // Adopt whatever the pre-scoping release left queued in the global + // retention database BEFORE the scoped reconcile and flush run, so + // stranded tombstones and archive requests publish on this boot + // instead of being abandoned by the storage cutover. + migrate_legacy_retention_into(&restore_app, &scope); + crate::event_sync::spawn_event_sync( + restore_app.clone(), + scope.owner_keys, + scope.db_path, + ) + } + Err(error) => { + eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}"); + } + } + let restore_pending = state .managed_agent_restore_pending .swap(false, Ordering::AcqRel); diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index 2ec5aa1c0ee..d9fe6acdb99 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -13,10 +13,10 @@ use std::path::Path; /// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`] /// (see its `# Ordering` guard). Event signing needs the resolved owner keys, /// so this runs after identity resolution, not in the boot migrations. -pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { - migrate_personas_to_events(app, owner_keys); - migrate_teams_to_events(app, owner_keys); - crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys); +pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: &Path) { + migrate_personas_to_events(app, owner_keys, db_path); + migrate_teams_to_events(app, owner_keys, db_path); + crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path); } /// Spawn the best-effort event reconcile off the synchronous Tauri setup path. @@ -25,10 +25,14 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { /// `AppState::keys` mutex. The reconcile itself is still synchronous JSON, /// SQLite, and signing work, so it runs on the blocking pool rather than an /// async worker. -pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) { +pub fn spawn_event_sync( + app: tauri::AppHandle, + owner_keys: nostr::Keys, + db_path: std::path::PathBuf, +) { tauri::async_runtime::spawn(async move { if let Err(e) = tauri::async_runtime::spawn_blocking(move || { - run_event_sync(&app, &owner_keys); + run_event_sync(&app, &owner_keys, &db_path); }) .await { @@ -57,14 +61,14 @@ pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) { /// `pending_sync = 1` for later relay publish. Migration succeeds on local /// write, not relay acknowledgment. Every retained row is a real signed /// event — there is no placeholder path. -pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { +pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) { use crate::managed_agents::managed_agents_base_dir; let Ok(base_dir) = managed_agents_base_dir(app) else { return; }; - match migrate_personas_in_dir(&base_dir, keys) { + match migrate_personas_in_dir_at(&base_dir, keys, db_path) { Ok(0) => {} Ok(migrated) => { eprintln!( @@ -82,7 +86,16 @@ pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { /// Returns the number of personas (re)written to the retention store. Returns /// `Ok(0)` when every non-builtin persona already has a matching retained row /// (or there are none to reconcile). +#[cfg(test)] fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + migrate_personas_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn migrate_personas_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { use crate::managed_agents::{ persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, @@ -127,9 +140,8 @@ fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result Result Result {} Ok(migrated) => { eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); @@ -225,7 +242,16 @@ pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { /// Returns the number of teams (re)written to the retention store. The /// per-coordinate content compare matches [`migrate_personas_in_dir`]: an /// unchanged team is skipped so a launch does not churn `pending_sync`. +#[cfg(test)] fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + migrate_teams_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn migrate_teams_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { use crate::managed_agents::{ persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, @@ -252,9 +278,8 @@ fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result k.clone(), - Err(e) => { - eprintln!("buzz-desktop: fatal: owner keys lock poisoned: {e}"); - std::process::exit(1); - } - }; - // Backfill the pinned persona snapshot for any pre-existing agent // that predates the record-authoritative-spawn cutover (persona_id // set but no source_version). Must run before @@ -547,15 +539,6 @@ pub fn run() { try_regenerate_nest(&app_handle); - // Sync team-dir edits and reconcile persona/team/agent events after - // setup can continue. It is best-effort retention backfill, unlike - // identity resolution above, so JSON/SQLite/signing work must not - // hold the boot path hostage. Skipped in recovery mode — the owner - // key is ephemeral. - if !recovery_mode { - event_sync::spawn_event_sync(app_handle.clone(), owner_keys); - } - if let Some(mgr) = huddle::models::global_model_manager() { mgr.start_stt_download(state.http_client.clone()); mgr.start_tts_download(state.http_client.clone()); @@ -638,17 +621,13 @@ pub fn run() { tauri::async_runtime::spawn(async move { use std::time::Duration; use tauri::Manager; - let Ok(db_path) = managed_agents::managed_agents_base_dir(&flush_handle) - .map(|d| d.join("retention.db")) - else { - eprintln!("buzz-desktop: event-flush: cannot resolve retention db path"); - return; - }; loop { let state = flush_handle.state::(); - if let Err(e) = - managed_agents::persona_events::flush_pending_events(&db_path, &state) - .await + if let Err(e) = managed_agents::persona_events::flush_active_pending_events( + &flush_handle, + &state, + ) + .await { eprintln!("buzz-desktop: event-flush: {e}"); } @@ -826,8 +805,10 @@ pub fn run() { list_personas, create_persona, update_persona, + update_persona_and_publish, delete_persona, set_persona_active, + set_persona_shared, reconcile_inbound_persona_event, list_channel_templates, create_channel_template, @@ -895,6 +876,7 @@ pub fn run() { validate_repos_dir, get_active_workspace, fetch_workspace_icon, + fetch_join_policy, set_prevent_sleep_active, get_agent_memory, relay_reconnect_hook, diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 13cb6c1b709..ebcc127683a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,5 +2,15 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Before anything else: WebKitGTK reads its rendering environment once at + // process start, and this is the only point where the process is still + // single threaded and no GTK object exists yet, which is what makes + // `std::env::set_var` sound. + #[cfg(target_os = "linux")] + if let Err(diagnostic) = buzz_lib::webkit_rendering::apply() { + eprintln!("buzz-desktop: {diagnostic}"); + std::process::exit(1); + } + buzz_lib::run() } diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index ba4407d164d..4a7b80079d8 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -208,8 +208,10 @@ mod tests { name_pool: vec!["poolname".to_string()], is_builtin: true, is_active: false, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f59913..16a0d35b23d 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -31,7 +31,11 @@ //! - lineage ids: `persona_id`, `team_id`, `source_team`, `source_team_persona_slug`, //! `persona_source_version` //! - internal bookkeeping: `start_on_app_launch`, -//! `auto_restart_on_config_change`, `is_builtin` +//! `auto_restart_on_config_change` +//! +//! The portable `sourceIsBuiltIn` hint preserves how the exported definition +//! should be described in an import preview. It never grants built-in status +//! to the newly imported definition. //! //! These exclusions are enforced by construction (only explicit fields are //! placed into `AgentSnapshotDefinition`) and asserted by unit tests. @@ -87,6 +91,10 @@ pub enum MemoryLevel { #[serde(rename_all = "camelCase")] pub struct AgentSnapshotDefinition { pub name: String, + /// Portable source classification for import-preview metadata. Imported + /// definitions are still created as custom agents with fresh identities. + #[serde(default)] + pub source_is_builtin: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -191,6 +199,7 @@ pub fn build_snapshot( .display_name .clone() .unwrap_or_else(|| record.name.clone()), + source_is_builtin: record.is_builtin, system_prompt: record.system_prompt.clone(), runtime: record.runtime.clone(), model: record.model.clone(), @@ -526,9 +535,11 @@ mod tests { name_pool: vec!["Alice".to_string(), "Bob".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: Some("team-id-123".to_string()), // MUST NOT appear source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear definition_respond_to: Some("allowlist".to_string()), + catalog_source: None, definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, @@ -913,6 +924,7 @@ mod tests { let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert!(!snapshot.definition.source_is_builtin); assert_eq!( snapshot.definition.system_prompt.as_deref(), Some("You are a test agent.") 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 4c11cd6c49e..4ee4ec79c32 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 @@ -106,8 +106,10 @@ fn test_record() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2b7264b4295..eecbf4de3ef 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -58,6 +58,12 @@ fn common_binary_paths() -> &'static [PathBuf] { .join("bin"), ); } + // Goose's legacy Windows installer (superseded by #2680) unpacked + // to %USERPROFILE%\goose\goose.exe, which is on no standard PATH — + // without this probe those installs stay permanently undiscovered. + if let Some(profile) = std::env::var_os("USERPROFILE") { + paths.push(PathBuf::from(profile).join("goose")); + } } paths }) @@ -80,7 +86,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_commands: &[], cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", adapter_install_instructions_url: "", - cli_install_hint: "Buzz requires the Goose CLI; the desktop app alone is not enough.", + cli_install_hint: "Buzz talks to Goose through the Goose CLI.", adapter_install_hint: "", skill_dir: Some(".goose/skills"), supports_acp_model_switching: false, @@ -112,8 +118,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Buzz requires the Claude Code CLI; the desktop app alone is not enough.", - adapter_install_hint: "Install the Claude Code ACP adapter via npm.", + cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", + adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -144,8 +150,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], cli_install_instructions_url: "https://developers.openai.com/codex/cli/", adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Buzz requires the Codex CLI; the desktop app alone is not enough.", - adapter_install_hint: "Install the Codex ACP adapter via npm.", + cli_install_hint: "Buzz talks to Codex through the Codex CLI.", + adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -597,7 +603,7 @@ pub fn clear_resolve_cache() { // // `build_managed_agent_summary` needs to compare the spawn-time adapter // availability against the *current* availability without triggering a live -// `probe_codex_acp_major_version` subprocess on every poll cycle. This cache +// `probe_codex_acp_version` subprocess on every poll cycle. This cache // stores the last availability status of the codex-acp binary at its resolved // path. It is warmed by `discover_acp_runtimes` (which already probes), so // the badge path reads warm data, and is invalidated by `clear_resolve_cache` @@ -1157,15 +1163,30 @@ pub(crate) fn classify_runtime( } } -/// Probe the major version of a `codex-acp` binary by running `--version`. +/// The oldest `codex-acp` version supported by Buzz managed agents. +/// +/// Older 1.x adapters are detected successfully, but can still bundle a Codex runtime +/// that does not reliably give `buzz` CLI subprocesses outbound relay access. +/// +/// Bump policy: raise this only when a newer adapter fixes a defect that breaks managed +/// agents, and only to a version already published on npm — every user below the floor is +/// offered a reinstall on their next discovery pass. +pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7); + +/// Probe the full version of a `codex-acp` binary by running `--version`. /// /// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs /// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. /// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does /// not recognise `--version` and exits non-zero. /// -/// Returns the major version on success, `None` on any failure (non-zero exit, -/// unparseable output, timeout, or missing binary). +/// Returns the `(major, minor, patch)` triple on success, `None` on any failure +/// (non-zero exit, unparseable output, timeout, or missing binary). +/// +/// The parse is deliberately strict: exactly three numeric dot-separated components. +/// Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and so +/// classify as [`AcpAvailabilityStatus::AdapterOutdated`] — failing closed offers a +/// reinstall rather than running an adapter whose version cannot be compared. /// /// The probe is bounded by a 5-second deadline. The child is polled with /// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and @@ -1174,16 +1195,16 @@ pub(crate) fn classify_runtime( /// Stdout is redirected to a temporary file rather than a pipe, so forked /// descendants cannot hold EOF open. Reads from a regular file return EOF at its /// current write position regardless of inherited file descriptors, cross-platform. -pub(crate) fn probe_codex_acp_major_version(binary_path: &Path) -> Option { - probe_codex_acp_major_version_with_path( +pub(crate) fn probe_codex_acp_version(binary_path: &Path) -> Option<(u64, u64, u64)> { + probe_codex_acp_version_with_path( binary_path, crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), ) } -pub(crate) fn probe_codex_acp_major_version_with_path( +pub(crate) fn probe_codex_acp_version_with_path( binary_path: &Path, augmented_path: Option<&str>, -) -> Option { +) -> Option<(u64, u64, u64)> { use std::io::{Read as _, Seek as _, SeekFrom}; use std::time::{Duration, Instant}; const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); @@ -1239,30 +1260,35 @@ pub(crate) fn probe_codex_acp_major_version_with_path( let stdout = String::from_utf8_lossy(&buf); // Output format: " .." let version_str = stdout.split_whitespace().last()?; - let major_str = version_str.split('.').next()?; - major_str.parse::().ok() + let mut components = version_str.split('.'); + let major = components.next()?.parse::().ok()?; + let minor = components.next()?.parse::().ok()?; + let patch = components.next()?.parse::().ok()?; + if components.next().is_some() { + return None; + } + Some((major, minor, patch)) } /// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] /// or [`AcpAvailabilityStatus::AdapterOutdated`]. /// /// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` -/// and exits non-zero — that probe failure yields `AdapterOutdated`. The 1.x adapter -/// (`@agentclientprotocol/codex-acp`) prints its version and exits 0; major ≥ 1 -/// yields `Available`. +/// and exits non-zero — that probe failure yields `AdapterOutdated`. An adapter is +/// available only when its version is at least [`MIN_CODEX_ACP_VERSION`]. /// /// Used by `discover_acp_runtimes`, `cli_login_requirements`, and /// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { - match probe_codex_acp_major_version(path) { - Some(major) if major >= 1 => AcpAvailabilityStatus::Available, + match probe_codex_acp_version(path) { + Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, _ => AcpAvailabilityStatus::AdapterOutdated, } } -/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) -/// or cannot be probed using `augmented_path`. Thin wrapper around -/// [`codex_adapter_is_outdated_with_path`]. +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed using `augmented_path`. Thin wrapper +/// around [`codex_adapter_is_outdated_with_path`]. #[cfg(test)] pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { codex_adapter_is_outdated_with_path( @@ -1271,15 +1297,15 @@ pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { ) } -/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) -/// or cannot be probed with the supplied PATH. +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed with the supplied PATH. pub(crate) fn codex_adapter_is_outdated_with_path( path: &Path, augmented_path: Option<&str>, ) -> bool { !matches!( - probe_codex_acp_major_version_with_path(path, augmented_path), - Some(major) if major >= 1 + probe_codex_acp_version_with_path(path, augmented_path), + Some(version) if version >= MIN_CODEX_ACP_VERSION ) } @@ -1302,9 +1328,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr let (mut availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: when the adapter resolves as Available, probe the - // version. An adapter with major version < 1 is treated as outdated — - // the CODEX_CONFIG spawn contract requires 1.x. + // For codex-acp: when the adapter resolves as Available, probe its full + // version. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && command.as_deref() == Some("codex-acp") @@ -1519,7 +1544,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "cursor-agent", args: &["acp"], install_instructions_url: "https://cursor.com/downloads", - install_hint: "Install Cursor from cursor.com/downloads.", + install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.", underlying_cli: None, }, PresetHarness { @@ -1528,7 +1553,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "omp", args: &["acp"], install_instructions_url: "https://github.com/can1357/oh-my-pi", - install_hint: "Install Oh My Pi from github.com/can1357/oh-my-pi.", + install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).", underlying_cli: None, }, PresetHarness { @@ -1537,7 +1562,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "grok", args: &["agent", "--always-approve", "stdio"], install_instructions_url: "https://build.x.ai/docs", - install_hint: "Install Grok Build from build.x.ai.", + install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.", underlying_cli: None, }, PresetHarness { @@ -1546,7 +1571,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "opencode", args: &["acp"], install_instructions_url: "https://opencode.ai/docs", - install_hint: "Install OpenCode from opencode.ai/docs.", + install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).", underlying_cli: None, }, PresetHarness { @@ -1555,7 +1580,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "kimi", args: &["acp"], install_instructions_url: "https://kimi.ai/download", - install_hint: "Install Kimi Code from kimi.ai/download.", + install_hint: "Buzz talks to Kimi Code through its CLI's ACP mode (kimi acp).", underlying_cli: None, }, PresetHarness { @@ -1564,7 +1589,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "amp-acp", args: &[], install_instructions_url: "https://github.com/tao12345666333/amp-acp", - install_hint: "Install the amp-acp npm adapter: npm install -g amp-acp.", + install_hint: "Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.", underlying_cli: Some("amp"), }, PresetHarness { @@ -1573,7 +1598,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "hermes-acp", args: &[], install_instructions_url: "https://hermes-agent.nousresearch.com", - install_hint: "Install Hermes Agent from hermes-agent.nousresearch.com.", + install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.", underlying_cli: None, }, PresetHarness { @@ -1582,7 +1607,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "openclaw", args: &["acp"], install_instructions_url: "https://docs.openclaw.ai/start/getting-started", - install_hint: "Install OpenClaw: npm install -g openclaw@latest.\n\n\ + install_hint: "Buzz talks to OpenClaw through its ACP mode (openclaw acp), which relies on the OpenClaw Gateway daemon. Follow the setup guide to install both.\n\n\ ⚠️ Execution-locus note: `openclaw acp` runs tools inside the \ OpenClaw Gateway daemon, not in the Desktop process. \ Desktop-injected BUZZ_* env vars are visible to the `openclaw` \ diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 2fb6a471d48..fdfe9b8be71 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -93,7 +93,7 @@ mod tests { "https://goose-docs.ai/docs/getting-started/installation/" ); assert!(goose.adapter_install_instructions_url.is_empty()); - assert!(goose.cli_install_hint.contains("desktop app alone")); + assert!(goose.cli_install_hint.contains("Goose CLI")); assert!(goose .cli_install_commands_windows .iter() @@ -111,7 +111,7 @@ mod tests { assert!(claude .adapter_install_instructions_url .contains("claude-agent-acp")); - assert!(claude.cli_install_hint.contains("desktop app alone")); + assert!(claude.cli_install_hint.contains("Claude Code CLI")); let codex = known_acp_runtime_exact("codex").unwrap(); assert_eq!( @@ -119,6 +119,6 @@ mod tests { "https://developers.openai.com/codex/cli/" ); assert!(codex.adapter_install_instructions_url.contains("codex-acp")); - assert!(codex.cli_install_hint.contains("desktop app alone")); + assert!(codex.cli_install_hint.contains("Codex CLI")); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 48e8d5479c4..1b587dca0e5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -6,7 +6,7 @@ use super::{ codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, preset_catalog_entry, probe_codex_acp_major_version, record_agent_command, + parse_semver_tag, preset_catalog_entry, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; @@ -46,10 +46,8 @@ fn returns_none_for_unknown_commands() { #[test] fn default_agent_command_resolves_bundled_buzz_agent() { - // The create-path default must be the bundled buzz-agent, never the - // bare `goose` that isn't on PATH on a stock Windows install. + // The default must be bundled buzz-agent, never bare `goose` on a stock Windows install. assert_eq!(default_agent_command(), "buzz-agent"); - // And buzz-agent takes no `acp` arg — confirm no arg leakage from the default. assert_eq!( normalize_agent_args(&default_agent_command(), vec!["acp".into()]), Vec::::new() @@ -285,8 +283,10 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -359,8 +359,10 @@ fn record_with( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -749,37 +751,41 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { assert_eq!(record_agent_command(&record, &personas), "codex-acp"); } -// ── probe_codex_acp_major_version ───────────────────────────────────────────── +// ── probe_codex_acp_version ─────────────────────────────────────────────────── mod managed_path_resolution; #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_parses_1x_output() { +fn probe_codex_acp_version_parses_full_semver_output() { use std::os::unix::fs::PermissionsExt; - // Simulate `@agentclientprotocol/codex-acp 1.1.2` output (1.x adapter) + // Simulate a current `@agentclientprotocol/codex-acp` output. let dir = std::env::temp_dir().join(format!("buzz-probe-1x-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let _ = std::fs::remove_dir_all(dir); - assert_eq!(major, Some(1), "1.x adapter must return major version 1"); + assert_eq!( + version, + Some((1, 1, 7)), + "adapter output must parse to its full semantic version" + ); } mod codex_version; #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_none_for_nonzero_exit() { +fn probe_codex_acp_version_returns_none_for_nonzero_exit() { use std::os::unix::fs::PermissionsExt; // Simulate old 0.16.x adapter: `--version` is unrecognised, exits non-zero @@ -789,21 +795,21 @@ fn probe_codex_acp_major_version_returns_none_for_nonzero_exit() { std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let _ = std::fs::remove_dir_all(dir); assert_eq!( - major, None, + version, None, "old 0.16.x adapter (non-zero exit) must return None" ); } #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_none_for_missing_binary() { +fn probe_codex_acp_version_returns_none_for_missing_binary() { let path = std::path::Path::new("/nonexistent/path/codex-acp-does-not-exist"); - let major = probe_codex_acp_major_version(path); - assert_eq!(major, None, "missing binary must return None"); + let version = probe_codex_acp_version(path); + assert_eq!(version, None, "missing binary must return None"); } // ── codex_adapter_availability / codex_adapter_is_outdated ─────────────────── @@ -813,7 +819,7 @@ fn probe_codex_acp_major_version_returns_none_for_missing_binary() { #[cfg(unix)] #[test] -fn codex_adapter_availability_available_for_1x_binary() { +fn codex_adapter_availability_available_for_minimum_supported_binary() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("buzz-avail-1x-{}", uuid::Uuid::new_v4())); @@ -821,7 +827,7 @@ fn codex_adapter_availability_available_for_1x_binary() { let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); @@ -832,7 +838,7 @@ fn codex_adapter_availability_available_for_1x_binary() { assert_eq!( status, AcpAvailabilityStatus::Available, - "1.x adapter must classify as Available" + "minimum supported adapter must classify as Available" ); } @@ -858,6 +864,53 @@ fn codex_adapter_availability_outdated_for_0x_binary() { ); } +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_older_1x_binary() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + assert_eq!( + codex_adapter_availability(&bin), + AcpAvailabilityStatus::AdapterOutdated, + "a 1.x adapter below the floor must be offered an upgrade" + ); +} + +/// The strict three-component parse fails closed: a version Buzz cannot compare +/// against the floor is treated as outdated rather than assumed current. +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_uncomparable_version() { + use std::os::unix::fs::PermissionsExt; + + for version in ["1.2", "1.2.0-rc1"] { + let dir = tempfile::tempdir().expect("temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write( + &bin, + format!("#!/bin/sh\necho '@agentclientprotocol/codex-acp {version}'\nexit 0\n"), + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod script"); + + assert_eq!( + codex_adapter_availability(&bin), + AcpAvailabilityStatus::AdapterOutdated, + "version {version} is not comparable to the floor and must fail closed" + ); + } +} + #[cfg(unix)] #[test] fn codex_adapter_availability_outdated_for_missing_binary() { @@ -876,7 +929,7 @@ fn codex_adapter_availability_outdated_for_missing_binary() { #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { +fn probe_codex_acp_version_returns_none_for_hung_direct_child() { use std::os::unix::fs::PermissionsExt; use std::time::Instant; @@ -894,12 +947,12 @@ fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let elapsed = start.elapsed(); let _ = std::fs::remove_dir_all(dir); assert_eq!( - major, None, + version, None, "hung binary must return None (timeout kills child)" ); // The timeout is 5 s; give a 10 s margin for parallel pre-push suites. @@ -911,7 +964,7 @@ fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open() { +fn probe_codex_acp_version_returns_version_when_descendant_holds_pipe_open() { use std::os::unix::fs::PermissionsExt; use std::time::Instant; @@ -923,20 +976,20 @@ fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open // (the parent closed its write end), read_to_end() returns immediately // without waiting for the descendant to close its inherited fd. // - // `(exec sleep 60 &)` forks a subshell that execs `sleep 60`; the subshell - // inherits the parent's stdout fd and keeps it open. + // `sleep 60 &` starts a descendant that inherits the parent's stdout fd + // without making the direct child wait for a nested subshell to exit. let dir = std::env::temp_dir().join(format!("buzz-probe-descendant-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n(exec sleep 60 &)\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nsleep 60 &\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let elapsed = start.elapsed(); let _ = std::fs::remove_dir_all(dir); @@ -947,9 +1000,9 @@ fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open "probe must not block on descendant pipe; elapsed: {elapsed:?}" ); assert_eq!( - major, - Some(1), - "1.x version must be parsed even when descendant holds pipe open" + version, + Some((1, 1, 2)), + "version must be parsed even when descendant holds pipe open" ); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs index 5886a439909..82bfd27f325 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs @@ -1,8 +1,8 @@ -use super::super::probe_codex_acp_major_version_with_path; +use super::super::probe_codex_acp_version_with_path; #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter() { +fn probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter() { use std::fs; use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); @@ -31,7 +31,7 @@ fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter .to_string_lossy() .into_owned(); assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&scrubbed_path)), + probe_codex_acp_version_with_path(&shim_path, Some(&scrubbed_path)), None, "with a scrubbed PATH, /usr/bin/env should not find node" ); @@ -41,8 +41,8 @@ fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter .to_string_lossy() .into_owned(); assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&augmented_path)), - Some(1), + probe_codex_acp_version_with_path(&shim_path, Some(&augmented_path)), + Some((1, 1, 2)), "the injected augmented PATH should allow /usr/bin/env to find node" ); } 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 2f6b038deb7..0795bb2345e 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 @@ -1,5 +1,31 @@ use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command}; +/// The legacy Goose Windows installer wrote `%USERPROFILE%\goose\goose.exe`, +/// a directory on no standard PATH. `resolve_command_uncached` finds binaries +/// outside PATH only by scanning `common_binary_paths()`, so that directory +/// must appear there or those installs stay undiscovered (#2239 residual). +/// +/// Asserts the probe list rather than a planted binary: `common_binary_paths` +/// is a process-lifetime `OnceLock`, so a test cannot re-seed `USERPROFILE` +/// deterministically, and planting an executable under the real user profile +/// is not an acceptable test side effect. +#[cfg(windows)] +#[test] +fn common_binary_paths_probes_legacy_goose_install_dir() { + use std::path::PathBuf; + + let profile = std::env::var_os("USERPROFILE").expect("USERPROFILE is always set on Windows"); + let legacy_dir = PathBuf::from(profile).join("goose"); + + let probed = super::super::common_binary_paths(); + + assert!( + probed.contains(&legacy_dir), + "legacy Goose install dir {} must be probed, got: {probed:?}", + legacy_dir.display() + ); +} + #[cfg(unix)] #[test] fn resolve_command_prefers_buzz_managed_npm_shim_over_path() { 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 81c2611d5c5..c8e437809ce 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -18,8 +18,10 @@ fn definition( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -81,8 +83,10 @@ fn record( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, 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 33b93d8a52e..553596e226c 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -343,8 +343,10 @@ fn bare_record() -> ManagedAgentRecord { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -365,8 +367,10 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -624,8 +628,10 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs index e4b4c36967b..ffcdea8181d 100644 --- a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs +++ b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs @@ -4,7 +4,7 @@ pub(crate) fn buzz_managed_npm_prefix() -> Option { dirs::data_dir().map(|dir| dir.join("Buzz").join("node-tools")) } -const BUZZ_MANAGED_NODE_VERSION: &str = "v24.11.0"; +const BUZZ_MANAGED_NODE_VERSION: &str = "v24.18.0"; pub(crate) fn buzz_managed_node_root() -> Option { dirs::data_dir().map(|dir| dir.join("Buzz").join("runtimes").join("node")) diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb5..be9b07cf11f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -29,6 +29,7 @@ pub mod retention; mod runtime; mod runtime_commands; mod runtime_types; +pub(crate) mod snapshot_avatar; pub(crate) mod spawn_hash; pub(crate) mod storage; pub(crate) mod team_events; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a9593816036..d2c415e725c 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -422,8 +422,10 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -480,8 +482,10 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 5b62615a8c1..ea61a811dbc 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; -use buzz_core_pkg::kind::KIND_PERSONA; +use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; @@ -138,7 +138,11 @@ pub fn build_persona_event(record: &AgentDefinition) -> Result Result Result Result { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let owner_keys = state.signing_keys()?; + flush_pending_events_at(db_path, state, &relay_url, &owner_keys).await +} + +/// Resolve and flush only the currently active `(relay, owner)` scope. +/// +/// The scope snapshots its relay, owner keys, and database path together +/// before network work starts. Switching communities during the flush cannot +/// redirect rows from the old scope into the new relay. +pub async fn flush_active_pending_events( + app: &tauri::AppHandle, + state: &AppState, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await +} + +async fn flush_pending_events_at( + db_path: &std::path::Path, + state: &AppState, + relay_url: &str, + owner_keys: &nostr::Keys, ) -> Result { use crate::managed_agents::retention::{ deferred_behind_failed_tombstone, get_pending_sync, get_retained_event, mark_synced, @@ -228,6 +259,8 @@ pub async fn flush_pending_events( }; use nostr::JsonUtil; + let owner_pubkey = owner_keys.public_key().to_hex(); + let relay_api_base = crate::relay::relay_http_base_url(relay_url); let pending = { let conn = open_retention_db(db_path)?; get_pending_sync(&conn)? @@ -237,6 +270,9 @@ pub async fn flush_pending_events( let mut failed_tombstones: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); for row in pending { + if row.pubkey != owner_pubkey { + continue; + } if deferred_behind_failed_tombstone(row.kind, &row.pubkey, &row.d_tag, &failed_tombstones) { continue; // its tombstone failed this sweep; next sweep re-orders them } @@ -270,9 +306,14 @@ pub async fn flush_pending_events( event }; - if crate::relay::submit_signed_event(&event, state) - .await - .is_err() + if crate::relay::submit_signed_event_at_with_keys( + &event, + state, + &relay_api_base, + owner_keys, + ) + .await + .is_err() { if current.kind == 5 { failed_tombstones.insert((current.pubkey.clone(), current.d_tag.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 27d3b0ce066..b9542f9a879 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -50,8 +50,10 @@ fn sample_record() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -149,8 +151,10 @@ fn sample_persona() -> AgentDefinition { name_pool: vec!["Alpha".to_string(), "Beta".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: Some("test-slug".to_string()), + catalog_source: None, env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), respond_to: None, respond_to_allowlist: Vec::new(), @@ -250,6 +254,25 @@ fn build_persona_event_produces_correct_kind() { assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); } +#[test] +fn shared_persona_event_has_exact_tag_and_round_trips() { + let mut record = sample_persona(); + record.shared = true; + let event = build_persona_event(&record) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + let shared_tags: Vec> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|part| part == "shared")) + .map(|tag| tag.as_slice().iter().map(String::as_str).collect()) + .collect(); + assert_eq!(shared_tags, vec![vec!["shared", "true"]]); + assert!(persona_from_event(&event).unwrap().shared); +} + #[test] fn round_trip_serialization() { let record = sample_persona(); @@ -355,8 +378,10 @@ fn content_matches_nip_ap_vector() { name_pool: vec!["Alpha".to_string(), "Beta".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -384,8 +409,10 @@ fn round_trip_minimal_persona() { name_pool: vec![], is_builtin: true, is_active: false, + shared: false, source_team: Some("team-1".to_string()), source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -479,8 +506,10 @@ fn quad_absent_definition_hash_stable_across_activation() { name_pool: vec!["nib".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -521,8 +550,10 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef name_pool: content.name_pool, is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: content.respond_to, respond_to_allowlist: content.respond_to_allowlist, @@ -880,6 +911,7 @@ mod flush_barrier { } let state = build_app_state(); + *state.keys.lock().unwrap() = keys; *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b0d874dc782..9bf7ab74b01 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -121,8 +121,10 @@ fn built_in_persona_records(now: &str) -> Vec { name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), is_builtin: true, is_active: persona.default_active, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -254,10 +256,7 @@ pub fn ensure_persona_is_active( .ok_or_else(|| format!("agent {persona_id} not found"))?; if !persona.is_active { - return Err(format!( - "{} is not in My Agents. Choose it from Agent Catalog first.", - persona.display_name - )); + return Err(format!("{} is not in My Agents.", persona.display_name)); } Ok(()) diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index e924345e8be..387b4d72c65 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -18,8 +18,10 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -171,10 +173,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() { let err = ensure_persona_is_active(&[persona], "builtin:fizz").unwrap_err(); - assert_eq!( - err, - "Fizz is not in My Agents. Choose it from Agent Catalog first." - ); + assert_eq!(err, "Fizz is not in My Agents."); } #[test] @@ -317,6 +316,7 @@ fn migrate_preserves_customized_personas() { system_prompt: "My custom research workflow with special instructions".to_string(), is_builtin: false, is_active: true, + shared: false, ..custom_persona("builtin:researcher", "My Researcher") }]; @@ -350,6 +350,7 @@ fn migrate_is_idempotent() { system_prompt: "My custom prompt".to_string(), is_builtin: false, is_active: false, + shared: false, ..custom_persona("builtin:researcher", "Researcher (retired)") }]; assert!( @@ -365,6 +366,7 @@ fn migrate_is_idempotent() { system_prompt: "Custom review prompt".to_string(), is_builtin: true, is_active: true, + shared: false, ..custom_persona("builtin:reviewer", "Reviewer") }]; assert!(migrate_retired_personas(&mut stored_pre_demotion, now)); diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c5480b24793..c053d933c53 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1510,8 +1510,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index dc73bc97392..90f05c5750d 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -32,12 +32,16 @@ use nostr::JsonUtil; /// Reconcile `managed-agents.json` into kind:30177 events in the retention /// store. Boot-time entry point, called from `event_sync::run_event_sync` /// after the persona and team legs. -pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { +pub(crate) fn reconcile_agents_to_events( + app: &tauri::AppHandle, + keys: &nostr::Keys, + db_path: &Path, +) { let Ok(base_dir) = super::managed_agents_base_dir(app) else { return; }; - match reconcile_agents_in_dir(&base_dir, keys) { + match reconcile_agents_in_dir_at(&base_dir, keys, db_path) { Ok(0) => {} Ok(reconciled) => { eprintln!( @@ -61,7 +65,16 @@ pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::K /// never churns `pending_sync`. /// /// Returns the number of agents (re)written to the retention store. +#[cfg(test)] pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + reconcile_agents_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn reconcile_agents_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { let store_path = base_dir.join("managed-agents.json"); if !store_path.exists() { return Ok(0); @@ -79,11 +92,8 @@ pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Re return Ok(0); } - let owner_pubkey = keys.public_key().to_hex(); - - let db_path = base_dir.join("retention.db"); let conn = - open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; + open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?; let mut reconciled = 0u32; @@ -94,46 +104,66 @@ pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Re continue; } - let existing = - get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; - - // Build the event first and compare ITS content, so the comparison and - // the retained row share one serialization of the projection (mirrors - // `migrate_personas_in_dir`). Serializing the projection independently - // here would silently diverge if `build_agent_event` ever changed how - // it serializes — republishing every agent every boot. Content is - // timestamp-independent, so the monotonic bump below never forces a - // spurious republish; an unchanged agent is still a true no-op. - let event = build_agent_event(record)? - .custom_created_at(monotonic_created_at( - existing.as_ref().map(|row| row.created_at), - )) - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event for '{}': {e}", record.name))?; - - let content = event.content.clone(); - if existing.as_ref().is_some_and(|row| row.content == content) { - continue; + if retain_agent_record(&conn, keys, record)? { + reconciled += 1; } - - retain_event( - &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey.clone(), - d_tag: record.pubkey.clone(), - content, - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - .map_err(|e| format!("failed to retain '{}': {e}", record.name))?; - reconciled += 1; } Ok(reconciled) } +/// Retain `record`'s kind:30177 identity record, marking it `pending_sync` +/// for the flush loop, when its projection differs from the retained head. +/// Returns `Ok(true)` when a row was (re)written and `Ok(false)` when the +/// retained content already matches (a true no-op — no `pending_sync` churn). +/// +/// This is the single content-diff + monotonic-bump engine shared by the +/// boot-time reconcile above and the interactive edit paths +/// (`retain_managed_agent_pending`, persona-rename propagation). Every +/// mutation of an agent's published identity must go through it so the +/// retained record can never silently drift from `managed-agents.json`. +pub(crate) fn retain_agent_record( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + record: &ManagedAgentRecord, +) -> Result { + let owner_pubkey = keys.public_key().to_hex(); + let existing = get_retained_event(conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + + // Build the event first and compare ITS content, so the comparison and + // the retained row share one serialization of the projection (mirrors + // `migrate_personas_in_dir`). Serializing the projection independently + // here would silently diverge if `build_agent_event` ever changed how + // it serializes — republishing every agent every boot. Content is + // timestamp-independent, so the monotonic bump below never forces a + // spurious republish; an unchanged agent is still a true no-op. + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event for '{}': {e}", record.name))?; + + let content = event.content.clone(); + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(false); + } + + retain_event( + conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content, + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .map_err(|e| format!("failed to retain '{}': {e}", record.name))?; + Ok(true) +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index 5af05f99d6e..c9269dbf002 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -300,3 +300,103 @@ fn slimming_republish_wave_is_one_time() { "second boot must be a no-op (idempotence)" ); } + +// ── retain_agent_record (interactive-edit engine) ──────────────────────────── +// +// #2423: renaming an agent must re-retain its kind:30177 identity record +// IMMEDIATELY, not at the next boot-time reconcile. These tests pin the shared +// engine both the boot reconcile and the interactive edit paths +// (`retain_managed_agent_pending`, persona-rename propagation) run on. + +/// A rename re-retains the identity record under the SAME coordinate (the +/// agent pubkey) with the new name, queued for publish, with a created_at +/// strictly past the retained head so the relay's replaceable-event rule +/// accepts it. Without this, the relay keeps the old name→pubkey binding +/// until the next restart — the identity desync in #2423. +#[test] +fn rename_re_retains_identity_record_with_new_name() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let owner = keys.public_key().to_hex(); + let pubkey = "9".repeat(64); + let mut record = sample_record(&pubkey, "Fizz"); + + assert!(retain_agent_record(&conn, &keys, &record).unwrap()); + let first = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &pubkey) + .unwrap() + .unwrap(); + // Simulate the flush loop confirming the initial publish. + mark_synced( + &conn, + first.kind, + &first.pubkey, + &first.d_tag, + first.created_at, + &first.content, + ) + .unwrap(); + + record.name = "Spark".to_string(); + assert!( + retain_agent_record(&conn, &keys, &record).unwrap(), + "a renamed record must re-retain its identity record" + ); + + let row = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &pubkey) + .unwrap() + .unwrap(); + assert_eq!(row.d_tag, pubkey, "coordinate stays keyed by agent pubkey"); + assert!( + row.content.contains("Spark"), + "retained identity record must carry the new name" + ); + assert!( + !row.content.contains("Fizz"), + "the stale name must not survive the rename" + ); + assert!(row.pending_sync, "a rename must queue a republish"); + assert!( + row.created_at > first.created_at, + "created_at must bump past the retained head (replaceable-event rule)" + ); +} + +/// An unchanged record is a true no-op: no rewrite, no `pending_sync` churn. +/// This is what lets every edit path call the engine unconditionally. +#[test] +fn retain_agent_record_is_noop_when_unchanged() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let pubkey = "8".repeat(64); + let record = sample_record(&pubkey, "steady-agent"); + + assert!(retain_agent_record(&conn, &keys, &record).unwrap()); + let row = get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + mark_synced( + &conn, + row.kind, + &row.pubkey, + &row.d_tag, + row.created_at, + &row.content, + ) + .unwrap(); + + assert!( + !retain_agent_record(&conn, &keys, &record).unwrap(), + "an unchanged projection must not re-retain" + ); + assert!( + get_pending_sync(&conn).unwrap().is_empty(), + "no pending_sync churn for an unchanged record" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 5df566dbbea..7e97fa1f566 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -5,10 +5,106 @@ //! keyed on `(kind, pubkey, d_tag)`, replacing only on a newer-or-equal //! `created_at` for NIP-33 latest-wins semantics. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use rusqlite::{params, Connection, OptionalExtension}; +use sha2::{Digest, Sha256}; +use tauri::AppHandle; + +use crate::app_state::AppState; + +mod legacy_migration; +pub use legacy_migration::migrate_legacy_retention_db; + +/// Durable event-retention scope for one community relay and owner identity. +/// +/// Persona, team, and managed-agent definitions are workspace-global, but +/// their relay heads and pending publications are not. Keeping a separate +/// database per `(relay_url, owner_pubkey)` prevents a pending write created in +/// community A from being drained into community B after a workspace switch. +pub struct RetentionScope { + pub db_path: PathBuf, + pub relay_url: String, + pub owner_keys: nostr::Keys, +} + +/// Decide whether `scope` — the workspace's active retention scope — is the one +/// that owns an event delivered by `arrival_relay_url`. +/// +/// Inbound reconcile resolves its retention database when it PROCESSES an event, +/// while the event belongs to the community that DELIVERED it. `None` means a +/// workspace switch happened in between and the caller must drop the event +/// rather than file community A's event into community B's store. +/// +/// The comparison goes through the same normalization +/// [`scoped_retention_db_path`] hashes, so "same relay" can never disagree with +/// "same database". +pub fn scope_for_arrival(scope: RetentionScope, arrival_relay_url: &str) -> Option { + let same_scope = + normalized_relay_scope(&scope.relay_url) == normalized_relay_scope(arrival_relay_url); + same_scope.then_some(scope) +} + +/// Relay-URL form that identifies a retention scope: equivalent workspace URLs +/// (surrounding space, trailing slash) must resolve to one scope. +fn normalized_relay_scope(relay_url: &str) -> &str { + relay_url.trim().trim_end_matches('/') +} + +/// Resolve the retention database path for a relay + owner pair. +/// +/// The normalized scope is hashed so relay URLs never become path components. +/// Trimming a trailing slash keeps equivalent workspace URLs on one scope. +pub fn scoped_retention_db_path(base_dir: &Path, relay_url: &str, owner_pubkey: &str) -> PathBuf { + let normalized_relay = normalized_relay_scope(relay_url); + let mut hasher = Sha256::new(); + hasher.update(owner_pubkey.trim().to_ascii_lowercase().as_bytes()); + hasher.update(b"\0"); + hasher.update(normalized_relay.as_bytes()); + let scope_id = hex::encode(hasher.finalize()); + base_dir.join("retention").join(format!("{scope_id}.db")) +} + +/// Snapshot the active relay + owner and resolve their durable event store. +/// +/// Callers keep the returned relay and keys alongside the path whenever work +/// crosses an `.await`; a later workspace switch cannot retarget that work. +pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let owner_keys = state.signing_keys()?; + let base_dir = super::managed_agents_base_dir(app)?; + let db_path = + scoped_retention_db_path(&base_dir, &relay_url, &owner_keys.public_key().to_hex()); + let parent = db_path + .parent() + .ok_or_else(|| "retention scope path has no parent".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create retention scope directory: {error}"))?; + Ok(RetentionScope { + db_path, + relay_url, + owner_keys, + }) +} + +/// Snapshot the active relay + owner, but only when it is the scope that owns +/// events delivered by `arrival_relay_url`. +/// +/// Resolving the scope and matching it in one step is what closes the gap: the +/// returned scope is both the one that will be written to and the one the event +/// arrived on. `Ok(None)` means the arrival community is no longer active and +/// the caller must drop the event — see [`scope_for_arrival`]. +pub fn arrival_retention_scope( + app: &AppHandle, + state: &AppState, + arrival_relay_url: &str, +) -> Result, String> { + Ok(scope_for_arrival( + active_retention_scope(app, state)?, + arrival_relay_url, + )) +} /// A retained persona event row. #[derive(Debug, Clone)] @@ -368,6 +464,64 @@ pub fn get_retained_event( mod tests { use super::*; + #[test] + fn retention_scope_is_stable_and_separates_relay_and_owner() { + let base = Path::new("/tmp/buzz-retention-test"); + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let community_a = scoped_retention_db_path(base, "wss://a.example/", &owner_a); + assert_eq!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://b.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_b) + ); + } + + #[test] + fn test_arrival_relay_matching_agrees_with_database_identity() { + let base = Path::new("/tmp/buzz-retention-test"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let scope = |relay: &str| RetentionScope { + db_path: scoped_retention_db_path(base, relay, &owner), + relay_url: relay.to_string(), + owner_keys: keys.clone(), + }; + let community_a = scoped_retention_db_path(base, "wss://a.example", &owner); + + // "Same relay" and "same database" must never disagree: every URL the + // match accepts has to hash to the scope's own db path, and every URL it + // rejects has to hash somewhere else. + for equivalent in ["wss://a.example", "wss://a.example/", " wss://a.example "] { + assert_eq!( + scope_for_arrival(scope("wss://a.example"), equivalent).map(|scope| scope.db_path), + Some(community_a.clone()), + "{equivalent}" + ); + assert_eq!( + scoped_retention_db_path(base, equivalent, &owner), + community_a, + "{equivalent}" + ); + } + + assert!( + scope_for_arrival(scope("wss://b.example"), "wss://a.example").is_none(), + "an event from community A must not be filed while community B is active" + ); + assert_ne!( + scoped_retention_db_path(base, "wss://b.example", &owner), + community_a + ); + } + #[test] fn concurrent_open_waits_for_initialization_lock() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs new file mode 100644 index 00000000000..1975f5d6df9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs @@ -0,0 +1,212 @@ +//! One-time migration of the pre-scoping global retention database into the +//! active relay+owner scope. +//! +//! Before community scoping, every durable event lived in one +//! `/retention.db`. Scoped storage +//! ([`super::scoped_retention_db_path`]) reads a different file, so an upgrade +//! would otherwise abandon whatever the previous release left pending — +//! including signed kind:5 tombstones and NIP-IA archive requests queued while +//! offline, which no reconcile can reconstruct (boot reconcile rebuilds upserts +//! from records still on disk, and deletions have no reconcile at all). +//! +//! # Crash safety +//! +//! Two guards, each written transactionally, make the migration exactly-once +//! without a completion file: +//! +//! 1. A **claim** in the legacy database naming the scope that owns its rows. +//! Legacy rows were queued for whichever single relay the old build had +//! active, so exactly one scope may take them; every other scope skips. This +//! is what keeps the migration from fanning one community's pending events +//! out to all of them — the leak class scoping exists to close. +//! 2. A **marker** in the scoped database, committed in the same transaction as +//! the copied rows. A crash mid-copy therefore leaves neither rows nor +//! marker, and the next boot copies from scratch; once the marker is there +//! the copy never repeats. +//! +//! The relay dimension is not recoverable from the legacy file — only the owner +//! pubkey is — so the claiming scope is the first one this owner activates after +//! upgrading. That is the workspace the app restores at launch, i.e. the same +//! relay the stranded rows were queued for in all but a contrived +//! switch-before-first-flush case. + +use std::path::{Path, PathBuf}; + +use rusqlite::{params, Connection, OptionalExtension}; + +use super::{open_retention_db, RetainedEvent}; + +/// Marker/claim identifier for this migration. +const MIGRATION_NAME: &str = "legacy_global_retention_db"; + +/// The pre-scoping global retention database path. +pub fn legacy_retention_db_path(base_dir: &Path) -> PathBuf { + base_dir.join("retention.db") +} + +/// Copy the legacy global database's rows for `owner_pubkey` into the scoped +/// database at `scope_db_path`. +/// +/// Returns the number of rows copied — `0` both when there is nothing to do and +/// when another scope already claimed the legacy rows. Best-effort by design: +/// the caller logs a failure and proceeds, and the guards make a later retry +/// safe. +pub fn migrate_legacy_retention_db( + base_dir: &Path, + scope_db_path: &Path, + owner_pubkey: &str, +) -> Result { + let legacy_path = legacy_retention_db_path(base_dir); + if !legacy_path.exists() || legacy_path == scope_db_path { + return Ok(0); + } + + let scope_id = scope_identifier(scope_db_path); + let mut scope_conn = open_retention_db(scope_db_path)?; + if migration_marker_present(&scope_conn)? { + return Ok(0); + } + + let legacy_conn = open_retention_db(&legacy_path)?; + if !claim_legacy_rows(&legacy_conn, &scope_id)? { + return Ok(0); // another scope owns these rows + } + + let rows = legacy_rows_for_owner(&legacy_conn, owner_pubkey)?; + let copied = rows.len(); + + let transaction = scope_conn + .transaction() + .map_err(|e| format!("failed to open retention migration transaction: {e}"))?; + for row in &rows { + // The scoped database is authoritative for any coordinate it already + // holds: those rows were written after the upgrade, so they are newer + // than anything legacy by construction. Legacy rows only fill gaps. + transaction + .execute( + "INSERT INTO persona_events + (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT (kind, pubkey, d_tag) DO NOTHING", + params![ + row.kind, + row.pubkey, + row.d_tag, + row.content, + row.created_at, + row.raw_event, + row.pending_sync as i32, + ], + ) + .map_err(|e| format!("failed to copy legacy retained event: {e}"))?; + } + write_migration_marker(&transaction, &scope_id)?; + transaction + .commit() + .map_err(|e| format!("failed to commit retention migration: {e}"))?; + + Ok(copied) +} + +/// Read every retained row authored by `owner_pubkey` from the legacy database. +/// +/// Owner-filtered because the flush loop only publishes rows matching the +/// active owner anyway; a different identity's rows belong to that identity's +/// scope, not this one. +fn legacy_rows_for_owner( + conn: &Connection, + owner_pubkey: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE pubkey = ?1 + ORDER BY (kind != 5), created_at ASC", + ) + .map_err(|e| format!("failed to prepare legacy retention query: {e}"))?; + + let rows = stmt + .query_map(params![owner_pubkey], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query legacy retained events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read legacy retained row: {e}")) +} + +/// Identify a scope by its database file stem — the relay+owner hash +/// [`super::scoped_retention_db_path`] already computes. +fn scope_identifier(scope_db_path: &Path) -> String { + scope_db_path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_default() +} + +fn ensure_migration_table(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS retention_migrations ( + name TEXT PRIMARY KEY, + scope_id TEXT NOT NULL + );", + ) + .map_err(|e| format!("failed to create retention migration table: {e}")) +} + +fn migration_marker_present(conn: &Connection) -> Result { + ensure_migration_table(conn)?; + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM retention_migrations WHERE name = ?1)", + params![MIGRATION_NAME], + |row| row.get(0), + ) + .map_err(|e| format!("failed to read retention migration marker: {e}")) +} + +fn write_migration_marker(conn: &Connection, scope_id: &str) -> Result<(), String> { + ensure_migration_table(conn)?; + conn.execute( + "INSERT OR REPLACE INTO retention_migrations (name, scope_id) VALUES (?1, ?2)", + params![MIGRATION_NAME, scope_id], + ) + .map_err(|e| format!("failed to write retention migration marker: {e}"))?; + Ok(()) +} + +/// Record `scope_id` as the owner of the legacy rows, or confirm it already is. +/// +/// `INSERT OR IGNORE` then read-back is atomic enough for this purpose: the +/// loser of a race reads the winner's scope id and returns `false`. +fn claim_legacy_rows(legacy_conn: &Connection, scope_id: &str) -> Result { + ensure_migration_table(legacy_conn)?; + legacy_conn + .execute( + "INSERT OR IGNORE INTO retention_migrations (name, scope_id) VALUES (?1, ?2)", + params![MIGRATION_NAME, scope_id], + ) + .map_err(|e| format!("failed to claim legacy retention rows: {e}"))?; + + let claimed_by: Option = legacy_conn + .query_row( + "SELECT scope_id FROM retention_migrations WHERE name = ?1", + params![MIGRATION_NAME], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("failed to read legacy retention claim: {e}"))?; + + Ok(claimed_by.as_deref() == Some(scope_id)) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs new file mode 100644 index 00000000000..75da221320e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs @@ -0,0 +1,186 @@ +use super::*; +use crate::managed_agents::retention::{ + get_pending_sync, get_retained_event, retain_event, scoped_retention_db_path, + tombstone_retention_d_tag, +}; +use buzz_core_pkg::kind::KIND_PERSONA; + +const KIND_DELETE: u32 = 5; +const OWNER: &str = "a1b2c3"; + +fn pending_tombstone(d_tag: &str) -> RetainedEvent { + RetainedEvent { + kind: KIND_DELETE, + pubkey: OWNER.to_string(), + d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), + content: String::new(), + created_at: 1_700_000_000, + raw_event: format!(r#"{{"kind":5,"d":"{d_tag}"}}"#), + pending_sync: true, + } +} + +fn seed_legacy(base_dir: &Path, events: &[RetainedEvent]) { + let conn = open_retention_db(&legacy_retention_db_path(base_dir)).unwrap(); + for event in events { + retain_event(&conn, event).unwrap(); + } +} + +fn scope_path(base_dir: &Path, relay: &str) -> PathBuf { + let path = scoped_retention_db_path(base_dir, relay, OWNER); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + path +} + +#[test] +fn test_pending_legacy_tombstone_migrates_into_the_active_scope_and_stays_pending() { + let dir = tempfile::tempdir().unwrap(); + seed_legacy(dir.path(), &[pending_tombstone("retired-agent")]); + let scope = scope_path(dir.path(), "wss://a.example"); + + let copied = migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(); + + assert_eq!(copied, 1); + let conn = open_retention_db(&scope).unwrap(); + let migrated = get_retained_event( + &conn, + KIND_DELETE, + OWNER, + &tombstone_retention_d_tag(KIND_PERSONA, "retired-agent"), + ) + .unwrap() + .expect("legacy tombstone lands in the scoped db"); + assert!( + migrated.pending_sync, + "the tombstone must still be queued for the flush loop" + ); + assert_eq!( + migrated.raw_event, + pending_tombstone("retired-agent").raw_event + ); + assert_eq!(get_pending_sync(&conn).unwrap().len(), 1); +} + +#[test] +fn test_repeat_migration_of_the_same_scope_copies_nothing_further() { + let dir = tempfile::tempdir().unwrap(); + seed_legacy(dir.path(), &[pending_tombstone("retired-agent")]); + let scope = scope_path(dir.path(), "wss://a.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 1 + ); + + // Simulate the flush loop clearing the row, then boot again: the marker + // must stop the legacy row from being resurrected as pending. + let conn = open_retention_db(&scope).unwrap(); + conn.execute("UPDATE persona_events SET pending_sync = 0", []) + .unwrap(); + drop(conn); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 0 + ); + let conn = open_retention_db(&scope).unwrap(); + assert!( + get_pending_sync(&conn).unwrap().is_empty(), + "a published row must not be re-queued by a second migration pass" + ); +} + +#[test] +fn test_second_community_does_not_receive_another_communitys_legacy_rows() { + let dir = tempfile::tempdir().unwrap(); + seed_legacy(dir.path(), &[pending_tombstone("retired-agent")]); + let first = scope_path(dir.path(), "wss://a.example"); + let second = scope_path(dir.path(), "wss://b.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &first, OWNER).unwrap(), + 1 + ); + assert_eq!( + migrate_legacy_retention_db(dir.path(), &second, OWNER).unwrap(), + 0, + "legacy rows belong to exactly one relay scope" + ); + + let conn = open_retention_db(&second).unwrap(); + assert!(get_pending_sync(&conn).unwrap().is_empty()); +} + +#[test] +fn test_rows_authored_by_another_identity_are_left_behind() { + let dir = tempfile::tempdir().unwrap(); + let mut foreign = pending_tombstone("someone-elses"); + foreign.pubkey = "ffffff".to_string(); + seed_legacy(dir.path(), &[pending_tombstone("mine"), foreign]); + let scope = scope_path(dir.path(), "wss://a.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 1 + ); + + let conn = open_retention_db(&scope).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].pubkey, OWNER); +} + +#[test] +fn test_post_upgrade_scoped_row_is_not_overwritten_by_its_legacy_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let legacy_head = RetainedEvent { + kind: KIND_PERSONA, + pubkey: OWNER.to_string(), + d_tag: "reviewer".to_string(), + content: r#"{"display_name":"Old"}"#.to_string(), + created_at: 1_700_000_000, + raw_event: r#"{"content":"old"}"#.to_string(), + pending_sync: true, + }; + seed_legacy(dir.path(), &[legacy_head]); + let scope = scope_path(dir.path(), "wss://a.example"); + + // An edit made after the upgrade already occupies the coordinate. + let conn = open_retention_db(&scope).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_PERSONA, + pubkey: OWNER.to_string(), + d_tag: "reviewer".to_string(), + content: r#"{"display_name":"New"}"#.to_string(), + created_at: 1_700_000_500, + raw_event: r#"{"content":"new"}"#.to_string(), + pending_sync: true, + }, + ) + .unwrap(); + drop(conn); + + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(); + + let conn = open_retention_db(&scope).unwrap(); + let row = get_retained_event(&conn, KIND_PERSONA, OWNER, "reviewer") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 1_700_000_500); + assert_eq!(row.raw_event, r#"{"content":"new"}"#); +} + +#[test] +fn test_absent_legacy_database_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + let scope = scope_path(dir.path(), "wss://a.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 0 + ); + assert!(!legacy_retention_db_path(dir.path()).exists()); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 32b3b328e9b..f3b4cb67fd5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -21,7 +21,10 @@ pub(crate) use path::should_skip_claude_executable; pub(crate) use path::should_use_inherited; mod metadata; -pub(crate) use metadata::{resolve_effective_prompt_model_provider, runtime_metadata_env_vars}; +pub(crate) use metadata::{ + resolve_effective_prompt_model_provider, resolve_session_title, runtime_metadata_env_vars, + SESSION_TITLE_ENV_VAR, +}; mod stop; pub(crate) use stop::managed_agent_runtime_keys; @@ -766,6 +769,15 @@ pub fn spawn_agent_child( } else { command.env_remove("BUZZ_ACP_MODEL"); } + // Session title for the harness to pass out-of-band on `session/new`. The + // adapter names the session after it; it never reaches the prompt, so this + // is display metadata only. `spawn_config_hash` hashes the same resolve, so + // a rename raises the restart badge instead of leaving the process stale. + if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { + command.env(SESSION_TITLE_ENV_VAR, title); + } else { + command.env_remove(SESSION_TITLE_ENV_VAR); + } build_buzz_agent_provider_defaults(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( @@ -823,7 +835,8 @@ pub fn spawn_agent_child( "GIT_CONFIG_KEY_0", format!("credential.{relay_http_url}/git.helper"), ); - command.env("GIT_CONFIG_VALUE_0", cred_helper.display().to_string()); + let helper = cred_helper.to_string_lossy().replace('\\', "/"); + command.env("GIT_CONFIG_VALUE_0", helper); command.env( "GIT_CONFIG_KEY_1", format!("credential.{relay_http_url}/git.useHttpPath"), diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index ac9e3a0a3fc..288ce06b0ad 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -24,6 +24,39 @@ pub(crate) fn runtime_metadata_env_vars<'a>( vars } +/// Env var carrying the session title to the harness. Shared with +/// `spawn_hash` so the restart badge hashes the same key the spawn writes. +pub(crate) const SESSION_TITLE_ENV_VAR: &str = "BUZZ_ACP_SESSION_TITLE"; + +/// Resolve the session title for an agent: its `display_name` when it has one, +/// otherwise its unique `name` handle. `None` when both are blank, so the +/// caller clears the env var rather than exporting an empty title. +/// +/// Control characters are stripped **here**, not left to the harness: an +/// interior NUL cannot cross the environment boundary at all, so +/// `Command::env` fails the whole spawn rather than passing it through (see +/// the same guard applied to user-supplied env in `env_vars::merged_user_env`). +/// A display name that is nothing but control characters therefore falls back +/// to `name` instead of turning display chrome into a spawn failure. +/// +/// The harness still owns whitespace collapsing, the length cap, and channel +/// qualification — see `sanitize_session_title` and `compose_session_title` in +/// `buzz-acp`. +pub(crate) fn resolve_session_title(display_name: Option<&str>, name: &str) -> Option { + [display_name, Some(name)] + .into_iter() + .flatten() + .map(|value| { + value + .chars() + .filter(|c| !c.is_control()) + .collect::() + .trim() + .to_string() + }) + .find(|value| !value.is_empty()) +} + /// Resolve effective prompt/model/provider using definition-authoritative /// semantics for linked instances. /// @@ -49,3 +82,69 @@ pub(crate) fn resolve_effective_prompt_model_provider( None => (record_prompt, record_model, record_provider), } } + +#[cfg(test)] +mod tests { + use super::resolve_session_title; + + #[test] + fn resolve_session_title_prefers_display_name() { + assert_eq!( + resolve_session_title(Some("Fizz"), "fizz-1").as_deref(), + Some("Fizz") + ); + } + + #[test] + fn resolve_session_title_falls_back_to_name_when_display_name_blank() { + assert_eq!( + resolve_session_title(None, "fizz-1").as_deref(), + Some("fizz-1") + ); + assert_eq!( + resolve_session_title(Some(" "), "fizz-1").as_deref(), + Some("fizz-1") + ); + } + + #[test] + fn resolve_session_title_returns_none_when_both_are_blank() { + assert_eq!(resolve_session_title(Some(""), " "), None); + } + + #[test] + fn resolve_session_title_trims_surrounding_whitespace() { + assert_eq!( + resolve_session_title(Some(" Fizz "), "fizz-1").as_deref(), + Some("Fizz") + ); + } + + /// An interior NUL cannot cross the env boundary — `Command::env` returns + /// `Err` for the whole spawn. Stripping it here keeps a corrupted record + /// from turning display chrome into a spawn failure. + #[test] + fn resolve_session_title_strips_control_chars_that_would_fail_the_spawn() { + let title = resolve_session_title(Some("Fi\u{0}zz\u{7}"), "fizz-1") + .expect("a name with strippable controls still yields a title"); + assert_eq!(title, "Fizz"); + assert!(!title.contains('\u{0}')); + } + + /// A display name that is *only* control characters is not a title, so the + /// unique handle takes over rather than exporting an empty value. + #[test] + fn resolve_session_title_falls_back_to_name_when_display_name_is_all_control_chars() { + assert_eq!( + resolve_session_title(Some("\u{0}\u{1}"), "fizz-1").as_deref(), + Some("fizz-1") + ); + } + + /// Both candidates unusable — the caller clears the env var instead of + /// exporting a NUL-bearing or empty title. + #[test] + fn resolve_session_title_returns_none_when_both_candidates_are_control_chars_only() { + assert_eq!(resolve_session_title(Some("\u{0}"), "\u{0}"), None); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 8deb0c4da92..3f6ee996f6c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -53,8 +53,7 @@ fn identifier_exact_match_at_end_of_buffer() { #[test] fn longer_id_matches_when_short_prefix_also_present() { - // Searching for the longer ID finds it even when a shorter prefix token - // appears earlier — Thufir's "longer-of-prefix must match" case. + // The longer ID still matches when a shorter prefix token appears earlier. let mut buf = b"xyz.block.buzz.app".to_vec(); buf.push(0); buf.extend_from_slice(br#""identifier":"xyz.block.buzz.app.dev""#); @@ -72,8 +71,7 @@ fn identifier_empty_returns_false() { #[test] fn marker_entry_is_namespaced_by_instance_id() { - // The spawn stamp and the sweep matcher must produce identical bytes; - // both go through buzz_marker_entry, so this pins the on-the-wire + // The spawn stamp and sweep matcher both go through buzz_marker_entry, pinning the on-the-wire // format and guards against a dev build (`...app.dev`) matching a // release build's (`...app`) agents. assert_eq!( @@ -175,8 +173,10 @@ fn fixture( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -294,8 +294,10 @@ fn persona_with_provider( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs b/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs new file mode 100644 index 00000000000..a1044b31f46 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs @@ -0,0 +1,42 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use image::ImageDecoder; +use std::io::Cursor; + +const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; +const MAX_AVATAR_DIMENSION: u32 = 2048; +const MAX_AVATAR_DECODE_ALLOC: u64 = 32 * 1024 * 1024; + +/// Materialize a snapshot PNG's visible pixels as a bounded portable avatar. +/// The exact transparent 1×1 no-avatar placeholder and images that cannot fit +/// the persisted inline-avatar budget leave the manifest fallback intact. +pub(crate) fn snapshot_png_avatar_data_url(png_bytes: &[u8]) -> Result, String> { + let reader = image::ImageReader::with_format(Cursor::new(png_bytes), image::ImageFormat::Png); + let mut decoder = reader + .into_decoder() + .map_err(|e| format!("Failed to decode snapshot avatar: {e}"))?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_AVATAR_DIMENSION); + limits.max_image_height = Some(MAX_AVATAR_DIMENSION); + limits.max_alloc = Some(MAX_AVATAR_DECODE_ALLOC); + decoder + .set_limits(limits) + .map_err(|e| format!("Snapshot avatar exceeds safe decoding limits: {e}"))?; + let (width, height) = decoder.dimensions(); + let image = image::DynamicImage::from_decoder(decoder) + .map_err(|e| format!("Failed to decode snapshot avatar: {e}"))?; + if width == 1 && height == 1 && image.to_rgba8().get_pixel(0, 0).0 == [0, 0, 0, 0] { + return Ok(None); + } + + let mut clean_png = Vec::new(); + image + .write_to(&mut Cursor::new(&mut clean_png), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode snapshot avatar: {e}"))?; + if clean_png.len() > MAX_AVATAR_INLINE_BYTES { + return Ok(None); + } + Ok(Some(format!( + "data:image/png;base64,{}", + STANDARD.encode(clean_png) + ))) +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index e2884bfa153..648cc62bbed 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -31,6 +31,7 @@ use super::{ effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, persona_events::preview_prospective_persona_snapshot, + runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, GlobalAgentConfig, }; @@ -124,6 +125,16 @@ pub(crate) fn spawn_config_hash( resolved_prompt.hash(&mut hasher); resolved_model.hash(&mut hasher); resolved_provider.hash(&mut hasher); + // Session title: the same resolve `spawn_agent_child` performs for its env + // write, so a rename raises the restart badge. Skipped when a user env + // override shadows it — spawn writes the title BEFORE the user env layer, + // so the override is what actually runs, and it already reaches this hash + // through `descriptor.env` above. Hashing the record-derived value under an + // override would badge a rename that changes nothing. + let effective_session_title = (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR)) + .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) + .flatten(); + effective_session_title.hash(&mut hasher); record.auth_tag.hash(&mut hasher); record.respond_to.as_str().hash(&mut hasher); // The allowlist is hashed as the env receives it: spawn sets diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 292cdbbb79f..f4ad4048143 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -49,8 +49,10 @@ fn record() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -70,8 +72,10 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -543,6 +547,72 @@ fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { ); } +#[test] +fn display_name_edit_changes_hash() { + // The spawn writes BUZZ_ACP_SESSION_TITLE from display_name-or-name, so a + // rename must trip the badge: the running process keeps the old title + // until it restarts, and the operator has to be told that. + let rec = record(); + let mut renamed = record(); + renamed.display_name = Some("Fizz".into()); + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), + "a display-name rename changes the spawned session title and must badge" + ); +} + +#[test] +fn name_edit_changes_hash_when_display_name_is_absent() { + // With no display_name the title falls back to the unique handle, so the + // handle is what the env write carries and what must be hashed. + let rec = record(); + let mut renamed = record(); + renamed.name = "agent-2".into(); + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), + "the fallback title source must reach the hash too" + ); +} + +#[test] +fn display_name_edit_does_not_change_hash_under_an_explicit_title_override() { + // User env is written AFTER the Buzz-set title (last-wins), so an explicit + // BUZZ_ACP_SESSION_TITLE is what the child actually runs with. Renaming the + // record changes nothing about the spawned process, so badging it would be + // a false restart prompt. The override itself still reaches the hash + // through the effective env. + let mut rec = record(); + rec.env_vars + .insert("BUZZ_ACP_SESSION_TITLE".into(), "Pinned Title".into()); + let mut renamed = rec.clone(); + renamed.display_name = Some("Fizz".into()); + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), + "a rename shadowed by an explicit title override must not badge" + ); +} + +#[test] +fn title_override_edit_changes_hash() { + // Counterpart to the test above: the override is not inert — editing it + // changes what the child runs with and must badge. + let mut rec = record(); + rec.env_vars + .insert("BUZZ_ACP_SESSION_TITLE".into(), "Pinned Title".into()); + let mut edited = record(); + edited + .env_vars + .insert("BUZZ_ACP_SESSION_TITLE".into(), "Other Title".into()); + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()), + "editing an explicit title override must badge" + ); +} + #[test] fn linked_instance_prompt_model_provider_resolve_from_one_call() { // The prompt for a linked instance must track the definition, exactly diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index b9c6c7e6cde..f6f89ed8989 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -65,6 +65,41 @@ pub fn managed_agent_runtime_log_path( Ok(managed_agents_logs_dir(app)?.join(format!("{}.log", key.runtime_id()))) } +/// Log path to surface for an agent whose runtime is not tracked in memory: +/// the most recently written of its pair-scoped logs, falling back to the +/// legacy single-runtime path when the agent has not run since harnesses +/// became per (agent, relay) pair. +pub fn latest_managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { + match newest_agent_log_in_dir(&managed_agents_logs_dir(app)?, pubkey) { + Some(path) => Ok(path), + None => managed_agent_log_path(app, pubkey), + } +} + +/// Newest log in `dir` belonging to `pubkey` — either a pair-scoped +/// `{pubkey}__{relay_hash}.log` or the legacy `{pubkey}.log`. Ties break +/// toward the higher filename so the choice is deterministic. +fn newest_agent_log_in_dir(dir: &Path, pubkey: &str) -> Option { + let legacy_name = format!("{pubkey}.log"); + let pair_prefix = format!("{pubkey}__"); + fs::read_dir(dir) + .ok()? + .flatten() + .filter_map(|entry| { + let name = entry.file_name(); + let matches = name.to_str().is_some_and(|name| { + name == legacy_name || (name.starts_with(&pair_prefix) && name.ends_with(".log")) + }); + if !matches { + return None; + } + let modified = entry.metadata().ok()?.modified().ok()?; + Some((modified, name, entry.path())) + }) + .max_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))) + .map(|(_, _, path)| path) +} + /// The keyring operations the migration chokepoint needs. Abstracted so the /// migrate-and-strip decision logic ([`migrate_inline_key`]) can be unit-tested /// against a fake without touching the live OS keyring. @@ -786,597 +821,5 @@ pub fn meaningful_agent_error_from_log(path: &Path) -> Option { } #[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::HashMap; - use std::io::Write as _; - - use tempfile::NamedTempFile; - - use super::{ - agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, - KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, - }; - - /// In-memory [`KeyStore`] for testing the migrate decision without the OS - /// keyring. `reachable=false` simulates a backend outage; `fail_verify` - /// simulates a write whose read-back does not confirm. - struct FakeKeyStore { - reachable: bool, - fail_verify: bool, - stored: RefCell>, - write_count: RefCell, - read_count: RefCell, - } - - impl FakeKeyStore { - fn reachable() -> Self { - Self { - reachable: true, - fail_verify: false, - stored: RefCell::new(HashMap::new()), - write_count: RefCell::new(0), - read_count: RefCell::new(0), - } - } - fn unreachable() -> Self { - Self { - reachable: false, - fail_verify: false, - stored: RefCell::new(HashMap::new()), - write_count: RefCell::new(0), - read_count: RefCell::new(0), - } - } - fn verify_fails() -> Self { - Self { - reachable: true, - fail_verify: true, - stored: RefCell::new(HashMap::new()), - write_count: RefCell::new(0), - read_count: RefCell::new(0), - } - } - /// Seed a key as already present in the keyring. - fn with_key(self, name: &str, value: &str) -> Self { - self.stored - .borrow_mut() - .insert(name.to_string(), value.to_string()); - self - } - } - - impl KeyStore for FakeKeyStore { - fn probe(&self, _name: &str) -> KeyringProbe { - if self.reachable { - KeyringProbe::ReachableButEmpty - } else { - KeyringProbe::Unreachable - } - } - fn load(&self, name: &str) -> Result, String> { - // An unreachable backend errors on read (outage), distinct from a - // reachable backend returning `Ok(None)` for an absent entry. - if !self.reachable { - return Err("keyring backend unreachable".to_string()); - } - *self.read_count.borrow_mut() += 1; - Ok(self.stored.borrow().get(name).cloned()) - } - fn load_all_readonly(&self) -> Result>, String> { - if !self.reachable { - return Err("keyring backend unreachable".to_string()); - } - *self.read_count.borrow_mut() += 1; - let map = self.stored.borrow().clone(); - // Return None when completely empty (simulates no blob written yet). - if map.is_empty() { - Ok(None) - } else { - Ok(Some(map)) - } - } - fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> { - if self.fail_verify { - return Err("read-back verify failed".to_string()); - } - *self.write_count.borrow_mut() += 1; - self.stored - .borrow_mut() - .insert(name.to_string(), value.to_string()); - Ok(()) - } - fn store_all(&self, entries: &HashMap) -> Result<(), String> { - if !self.reachable { - return Err("keyring backend unreachable".to_string()); - } - if self.fail_verify { - return Err("read-back verify failed".to_string()); - } - *self.write_count.borrow_mut() += 1; - let mut stored = self.stored.borrow_mut(); - for (k, v) in entries { - stored.insert(k.clone(), v.clone()); - } - Ok(()) - } - } - - fn record_with_key(nsec: &str) -> ManagedAgentRecord { - record_with_pubkey_and_key("agent-pubkey", nsec) - } - - fn record_with_pubkey_and_key(pubkey: &str, nsec: &str) -> ManagedAgentRecord { - serde_json::from_str(&format!( - r#"{{ - "pubkey": "{pubkey}", - "name": "test-agent", - "private_key_nsec": "{nsec}", - "relay_url": "wss://localhost:3000", - "acp_command": "buzz-acp", - "agent_command": "goose", - "agent_args": [], - "mcp_command": "", - "turn_timeout_seconds": 320, - "created_at": "2026-01-01T00:00:00Z", - "updated_at": "2026-01-01T00:00:00Z" - }}"# - )) - .expect("sample record") - } - - #[test] - fn migrate_persists_and_signals_stripping_when_keyring_reachable() { - // Item 2: an inline key (residue from a prior keyring-unreachable save) - // is written to the keyring and verified when the backend is reachable, - // so the next save can drop it from JSON. - let store = FakeKeyStore::reachable(); - let record = record_with_key("nsec1realkey"); - - let outcome = migrate_inline_key(&store, &record); - - assert_eq!(outcome, KeyMigration::Persisted); - assert_eq!( - store - .stored - .borrow() - .get(&agent_keyring_name("agent-pubkey")) - .map(String::as_str), - Some("nsec1realkey") - ); - } - - #[test] - fn migrate_keeps_inline_when_keyring_unreachable() { - // No-resurrection guard: a transient outage must NOT migrate; the key - // stays inline (file fallback) so it is not lost. - let store = FakeKeyStore::unreachable(); - let record = record_with_key("nsec1realkey"); - - let outcome = migrate_inline_key(&store, &record); - - assert_eq!(outcome, KeyMigration::KeptInline); - assert!(store.stored.borrow().is_empty()); - } - - #[test] - fn migrate_keeps_inline_when_verify_fails() { - // A write whose read-back does not confirm must keep the key inline — - // never drop plaintext on an unverified write. - let store = FakeKeyStore::verify_fails(); - let record = record_with_key("nsec1realkey"); - - assert_eq!( - migrate_inline_key(&store, &record), - KeyMigration::KeptInline - ); - } - - #[test] - fn migrate_reports_nothing_for_empty_key() { - // A record whose key already lives in the keyring (empty inline) has - // nothing to migrate. It must NOT be reported as `Persisted` — an - // empty key after a keyring outage means the secret is unavailable, - // not verified present (Wes storage.rs:158). - let store = FakeKeyStore::reachable(); - let record = record_with_key(""); - - assert_eq!(migrate_inline_key(&store, &record), KeyMigration::Nothing); - assert!(store.stored.borrow().is_empty()); - } - - #[test] - fn hydrate_fills_key_from_keyring_when_reachable() { - // The normal keyring-backed case: an empty inline key is filled from - // the keyring on load. - let store = - FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-pubkey"), "nsec1stored"); - let mut records = vec![record_with_key("")]; - - hydrate_keys_with(&store, &mut records); - - assert_eq!(records[0].private_key_nsec, "nsec1stored"); - } - - #[test] - fn hydrate_leaves_key_empty_on_keyring_outage() { - // Outage edge (Wes storage.rs:158): when the keyring read ERRORS, the - // key must be left empty — never silently treated as resolved — so the - // spawn path refuses rather than launching the agent with no identity. - let store = FakeKeyStore::unreachable(); - let mut records = vec![record_with_key("")]; - - hydrate_keys_with(&store, &mut records); - - assert!( - records[0].private_key_nsec.is_empty(), - "an unreadable key must stay empty, not be fabricated" - ); - } - - #[test] - fn spawn_refused_when_private_key_empty() { - // The spawn path MUST refuse a record left empty by an outage/absence - // before injecting an empty BUZZ_PRIVATE_KEY / NOSTR_PRIVATE_KEY — never - // launch an agent with no identity (Wes storage.rs:158). - let record = record_with_key(""); - assert!( - super::spawn_key_refusal(&record).is_some(), - "an agent with no private key must be refused" - ); - } - - #[test] - fn spawn_allowed_when_private_key_present() { - // A record carrying a key must not be blocked by the refusal guard. - let record = record_with_key("nsec1realkey"); - assert!(super::spawn_key_refusal(&record).is_none()); - } - - #[test] - fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() { - // This is the dominant prompt-storm scenario: after the first successful - // persist all inline copies are cleared, so subsequent saves (e.g. a - // model change) must issue zero keychain writes. `migrate_inline_key` - // returns `Nothing` for empty-key records, and `persist_agent_keys_with` - // must propagate that guarantee — write_count stays at 0. - let store = FakeKeyStore::reachable(); - // Records whose inline key is already blank (key lives in the keyring). - let mut records = vec![record_with_key(""), record_with_key("")]; - - persist_agent_keys_with(&store, &mut records); - - assert_eq!( - *store.write_count.borrow(), - 0, - "a save with no inline keys must issue zero keychain writes" - ); - } - - #[test] - fn persist_agent_keys_writes_once_per_record_with_inline_key() { - // A record carrying an inline key (e.g. first save, or keyring-outage - // residue) must trigger exactly one write_and_verify per record — and - // once persisted the inline copy is cleared so the next save is free. - // Records use distinct pubkeys so each maps to a distinct keyring name, - // verifying the "per record" behaviour rather than a single-key overwrite. - let store = FakeKeyStore::reachable(); - let mut records = vec![ - record_with_pubkey_and_key("pubkey-agent-alpha", "nsec1key_a"), - record_with_pubkey_and_key("pubkey-agent-beta", "nsec1key_b"), - ]; - - persist_agent_keys_with(&store, &mut records); - - assert_eq!( - *store.write_count.borrow(), - 2, - "each record with an inline key must trigger exactly one write" - ); - // Verify the correct keyring name was used for each agent. - assert_eq!( - store - .stored - .borrow() - .get(&agent_keyring_name("pubkey-agent-alpha")) - .map(String::as_str), - Some("nsec1key_a"), - ); - assert_eq!( - store - .stored - .borrow() - .get(&agent_keyring_name("pubkey-agent-beta")) - .map(String::as_str), - Some("nsec1key_b"), - ); - // After persist the inline copies are cleared — next save is zero-write. - assert!(records[0].private_key_nsec.is_empty()); - assert!(records[1].private_key_nsec.is_empty()); - } - - fn write_log(content: &str) -> NamedTempFile { - let mut file = NamedTempFile::new().expect("temp log"); - file.write_all(content.as_bytes()).expect("write log"); - file - } - - /// The keyringless fallback write must land `0o600` from the write itself — - /// not a post-write `chmod` — so a crash in the umask window can never leave - /// plaintext agent nsecs world-readable (Wes storage.rs:239, SECURITY.md:90). - #[cfg(unix)] - #[test] - fn restricted_write_lands_owner_only_without_post_write_chmod() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("managed-agents.json"); - - super::atomic_write_json_restricted(&path, br#"[{"private_key_nsec":"nsec1secret"}]"#) - .expect("restricted write"); - - let mode = std::fs::metadata(&path) - .expect("metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600, "secret-bearing write must be owner-only"); - assert_eq!( - std::fs::read_to_string(&path).expect("read back"), - r#"[{"private_key_nsec":"nsec1secret"}]"# - ); - } - - #[test] - fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { - let file = write_log( - "noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n", - ); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert!(result.message.contains("llm auth")); - assert_eq!(result.code, Some(-32001)); - } - - #[test] - fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { - let file = write_log("noise\nllm auth: denied\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!(result.message, "Agent reported error: llm auth: denied"); - assert_eq!(result.code, Some(-32001)); - } - - #[test] - fn meaningful_agent_error_from_log_promotes_bare_model_not_found() { - let file = write_log("noise\nllm model not found: (some-model) 404\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!( - result.message, - "Agent reported error: llm model not found: (some-model) 404" - ); - assert_eq!(result.code, Some(-32002)); - } - - #[test] - fn meaningful_agent_error_from_log_promotes_legacy_format() { - let file = write_log("noise\nAgent reported error: llm: 500 internal\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!(result.message, "Agent reported error: llm: 500 internal"); - assert_eq!(result.code, None); - } - - #[test] - fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() { - let file = write_log("noise before llm auth: denied\n"); - assert!(super::meaningful_agent_error_from_log(file.path()).is_none()); - } - - #[test] - fn strips_ansi_from_typical_tracing_line() { - let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mbuzz_acp\x1b[0m\x1b[2m:\x1b[0m starting"; - assert_eq!( - strip_ansi_escapes::strip_str(input), - "2026-05-27T15:16:32 INFO buzz_acp: starting" - ); - } - - // ── keyring-dev-migration tests ──────────────────────────────────────── - - #[test] - fn copy_agent_keys_copies_keys_present_in_src_to_dst() { - // Keys in src but not in dst must be copied in a single bulk write, - // and the migration-complete marker must be set. - let src = FakeKeyStore::reachable() - .with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha") - .with_key(&agent_keyring_name("agent-beta"), "nsec1beta"); - let dst = FakeKeyStore::reachable(); - - super::copy_agent_keys_between_stores( - &["agent-alpha".to_string(), "agent-beta".to_string()], - &src, - &dst, - ); - - assert_eq!( - dst.stored - .borrow() - .get(&agent_keyring_name("agent-alpha")) - .map(String::as_str), - Some("nsec1alpha"), - "agent-alpha must be copied from src to dst" - ); - assert_eq!( - dst.stored - .borrow() - .get(&agent_keyring_name("agent-beta")) - .map(String::as_str), - Some("nsec1beta"), - "agent-beta must be copied from src to dst" - ); - assert_eq!( - dst.stored - .borrow() - .get(super::DEV_MIGRATION_MARKER) - .map(String::as_str), - Some("done"), - "migration-complete marker must be set after first migration" - ); - // Bulk write: exactly 1 store_all call. - assert_eq!( - *dst.write_count.borrow(), - 1, - "must perform exactly one bulk write" - ); - // Src accessed exactly once (bulk blob read). - assert_eq!( - *src.read_count.borrow(), - 1, - "src must be read exactly once (bulk)" - ); - } - - #[test] - fn copy_agent_keys_skips_keys_already_in_dst() { - // Idempotency: a key already present in dst must NOT be overwritten - // — the agent may have rotated their key in the dev service. - let src = - FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1old"); - let dst = - FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1new"); - - super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); - - // dst value must remain unchanged — src must not overwrite it. - assert_eq!( - dst.stored - .borrow() - .get(&agent_keyring_name("agent-alpha")) - .map(String::as_str), - Some("nsec1new"), - "key already in dst must not be overwritten by migration" - ); - // Marker must still be written even though no new keys were copied. - assert_eq!( - dst.stored - .borrow() - .get(super::DEV_MIGRATION_MARKER) - .map(String::as_str), - Some("done"), - "marker must be set even when all keys are already present" - ); - assert_eq!(*src.read_count.borrow(), 0); - } - - #[test] - fn copy_agent_keys_skips_keys_absent_from_src() { - // A pubkey with no entry in src (new agent that will mint a fresh key) - // must be silently skipped — no agent key written to dst. - let src = FakeKeyStore::reachable(); // empty - let dst = FakeKeyStore::reachable(); - - super::copy_agent_keys_between_stores(&["new-agent".to_string()], &src, &dst); - - assert!( - dst.stored - .borrow() - .get(&agent_keyring_name("new-agent")) - .is_none(), - "absent src key must produce no agent key write to dst" - ); - // Marker must still be written. - assert_eq!( - dst.stored - .borrow() - .get(super::DEV_MIGRATION_MARKER) - .map(String::as_str), - Some("done"), - "marker must be set even when no keys were present in src" - ); - } - - #[test] - fn copy_agent_keys_skips_all_when_dst_unreachable() { - // When dst keyring is unreachable the migration must be a no-op — never - // data-loss (failing to write is fine; the agent will re-mint on next - // onboarding run). - let src = - FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha"); - let dst = FakeKeyStore::unreachable(); - - super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); - - // No writes attempted to an unreachable dst. - assert_eq!(*dst.write_count.borrow(), 0); - // Src must not have been accessed (failed on dst read, returned early). - assert_eq!( - *src.read_count.borrow(), - 0, - "src must not be accessed when dst is unreachable" - ); - } - - #[test] - fn copy_agent_keys_skips_entirely_when_marker_present() { - // After the first migration, the marker is in dst. Subsequent calls - // must return immediately — the prod keyring (src) must never be read. - let src = - FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha"); - let dst = FakeKeyStore::reachable() - .with_key(super::DEV_MIGRATION_MARKER, "done") - .with_key(&agent_keyring_name("agent-alpha"), "nsec1dev"); - - super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); - - // Src must not have been accessed at all. - assert_eq!( - *src.read_count.borrow(), - 0, - "src must not be read when migration-complete marker is present" - ); - // Dst must not have been written. - assert_eq!( - *dst.write_count.borrow(), - 0, - "dst must not be written when migration-complete marker is present" - ); - // Dev key must remain unchanged. - assert_eq!( - dst.stored - .borrow() - .get(&agent_keyring_name("agent-alpha")) - .map(String::as_str), - Some("nsec1dev"), - "dev key must not be overwritten on subsequent boots" - ); - } - - #[test] - fn copy_agent_keys_writes_marker_even_with_empty_agent_list() { - // An empty pubkey list (no agents yet) must still write the marker so - // future boots skip the prod read. - let src = FakeKeyStore::reachable(); - let dst = FakeKeyStore::reachable(); - - super::copy_agent_keys_between_stores(&[], &src, &dst); - - assert_eq!( - dst.stored - .borrow() - .get(super::DEV_MIGRATION_MARKER) - .map(String::as_str), - Some("done"), - "marker must be set even when pubkey list is empty" - ); - assert_eq!(*src.read_count.borrow(), 0); - } - - #[test] - fn try_delete_agent_key_returns_result() { - // Verify the result-returning seam exists and has the correct signature. - // We cannot call it in default builds (system-keyring feature is on, - // which accesses the real OS keychain and blocks in headless/CI). The - // real keychain paths are integration-tested through the #[ignore] - // tests in secret_store.rs; the rollback aggregation is tested in - // team_snapshot::tests::rollback_aggregates_multiple_errors. - let _: fn(&str) -> Result<(), String> = super::try_delete_agent_key; - } -} +#[path = "storage_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs new file mode 100644 index 00000000000..73567bb915c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -0,0 +1,700 @@ +//! Unit tests for `managed_agents/storage.rs`. +//! +//! Kept in a sibling file so `storage.rs` stays closer to the 1000-line gate; +//! `#[path]`-included from there. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs::File; +use std::io::Write as _; +use std::path::Path; + +use tempfile::NamedTempFile; + +use super::{ + agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, + KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, +}; + +/// In-memory [`KeyStore`] for testing the migrate decision without the OS +/// keyring. `reachable=false` simulates a backend outage; `fail_verify` +/// simulates a write whose read-back does not confirm. +struct FakeKeyStore { + reachable: bool, + fail_verify: bool, + stored: RefCell>, + write_count: RefCell, + read_count: RefCell, +} + +impl FakeKeyStore { + fn reachable() -> Self { + Self { + reachable: true, + fail_verify: false, + stored: RefCell::new(HashMap::new()), + write_count: RefCell::new(0), + read_count: RefCell::new(0), + } + } + fn unreachable() -> Self { + Self { + reachable: false, + fail_verify: false, + stored: RefCell::new(HashMap::new()), + write_count: RefCell::new(0), + read_count: RefCell::new(0), + } + } + fn verify_fails() -> Self { + Self { + reachable: true, + fail_verify: true, + stored: RefCell::new(HashMap::new()), + write_count: RefCell::new(0), + read_count: RefCell::new(0), + } + } + /// Seed a key as already present in the keyring. + fn with_key(self, name: &str, value: &str) -> Self { + self.stored + .borrow_mut() + .insert(name.to_string(), value.to_string()); + self + } +} + +impl KeyStore for FakeKeyStore { + fn probe(&self, _name: &str) -> KeyringProbe { + if self.reachable { + KeyringProbe::ReachableButEmpty + } else { + KeyringProbe::Unreachable + } + } + fn load(&self, name: &str) -> Result, String> { + // An unreachable backend errors on read (outage), distinct from a + // reachable backend returning `Ok(None)` for an absent entry. + if !self.reachable { + return Err("keyring backend unreachable".to_string()); + } + *self.read_count.borrow_mut() += 1; + Ok(self.stored.borrow().get(name).cloned()) + } + fn load_all_readonly(&self) -> Result>, String> { + if !self.reachable { + return Err("keyring backend unreachable".to_string()); + } + *self.read_count.borrow_mut() += 1; + let map = self.stored.borrow().clone(); + // Return None when completely empty (simulates no blob written yet). + if map.is_empty() { + Ok(None) + } else { + Ok(Some(map)) + } + } + fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> { + if self.fail_verify { + return Err("read-back verify failed".to_string()); + } + *self.write_count.borrow_mut() += 1; + self.stored + .borrow_mut() + .insert(name.to_string(), value.to_string()); + Ok(()) + } + fn store_all(&self, entries: &HashMap) -> Result<(), String> { + if !self.reachable { + return Err("keyring backend unreachable".to_string()); + } + if self.fail_verify { + return Err("read-back verify failed".to_string()); + } + *self.write_count.borrow_mut() += 1; + let mut stored = self.stored.borrow_mut(); + for (k, v) in entries { + stored.insert(k.clone(), v.clone()); + } + Ok(()) + } +} + +fn record_with_key(nsec: &str) -> ManagedAgentRecord { + record_with_pubkey_and_key("agent-pubkey", nsec) +} + +fn record_with_pubkey_and_key(pubkey: &str, nsec: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{pubkey}", + "name": "test-agent", + "private_key_nsec": "{nsec}", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"# + )) + .expect("sample record") +} + +#[test] +fn migrate_persists_and_signals_stripping_when_keyring_reachable() { + // Item 2: an inline key (residue from a prior keyring-unreachable save) + // is written to the keyring and verified when the backend is reachable, + // so the next save can drop it from JSON. + let store = FakeKeyStore::reachable(); + let record = record_with_key("nsec1realkey"); + + let outcome = migrate_inline_key(&store, &record); + + assert_eq!(outcome, KeyMigration::Persisted); + assert_eq!( + store + .stored + .borrow() + .get(&agent_keyring_name("agent-pubkey")) + .map(String::as_str), + Some("nsec1realkey") + ); +} + +#[test] +fn migrate_keeps_inline_when_keyring_unreachable() { + // No-resurrection guard: a transient outage must NOT migrate; the key + // stays inline (file fallback) so it is not lost. + let store = FakeKeyStore::unreachable(); + let record = record_with_key("nsec1realkey"); + + let outcome = migrate_inline_key(&store, &record); + + assert_eq!(outcome, KeyMigration::KeptInline); + assert!(store.stored.borrow().is_empty()); +} + +#[test] +fn migrate_keeps_inline_when_verify_fails() { + // A write whose read-back does not confirm must keep the key inline — + // never drop plaintext on an unverified write. + let store = FakeKeyStore::verify_fails(); + let record = record_with_key("nsec1realkey"); + + assert_eq!( + migrate_inline_key(&store, &record), + KeyMigration::KeptInline + ); +} + +#[test] +fn migrate_reports_nothing_for_empty_key() { + // A record whose key already lives in the keyring (empty inline) has + // nothing to migrate. It must NOT be reported as `Persisted` — an + // empty key after a keyring outage means the secret is unavailable, + // not verified present (Wes storage.rs:158). + let store = FakeKeyStore::reachable(); + let record = record_with_key(""); + + assert_eq!(migrate_inline_key(&store, &record), KeyMigration::Nothing); + assert!(store.stored.borrow().is_empty()); +} + +#[test] +fn hydrate_fills_key_from_keyring_when_reachable() { + // The normal keyring-backed case: an empty inline key is filled from + // the keyring on load. + let store = + FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-pubkey"), "nsec1stored"); + let mut records = vec![record_with_key("")]; + + hydrate_keys_with(&store, &mut records); + + assert_eq!(records[0].private_key_nsec, "nsec1stored"); +} + +#[test] +fn hydrate_leaves_key_empty_on_keyring_outage() { + // Outage edge (Wes storage.rs:158): when the keyring read ERRORS, the + // key must be left empty — never silently treated as resolved — so the + // spawn path refuses rather than launching the agent with no identity. + let store = FakeKeyStore::unreachable(); + let mut records = vec![record_with_key("")]; + + hydrate_keys_with(&store, &mut records); + + assert!( + records[0].private_key_nsec.is_empty(), + "an unreadable key must stay empty, not be fabricated" + ); +} + +#[test] +fn spawn_refused_when_private_key_empty() { + // The spawn path MUST refuse a record left empty by an outage/absence + // before injecting an empty BUZZ_PRIVATE_KEY / NOSTR_PRIVATE_KEY — never + // launch an agent with no identity (Wes storage.rs:158). + let record = record_with_key(""); + assert!( + super::spawn_key_refusal(&record).is_some(), + "an agent with no private key must be refused" + ); +} + +#[test] +fn spawn_allowed_when_private_key_present() { + // A record carrying a key must not be blocked by the refusal guard. + let record = record_with_key("nsec1realkey"); + assert!(super::spawn_key_refusal(&record).is_none()); +} + +#[test] +fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() { + // This is the dominant prompt-storm scenario: after the first successful + // persist all inline copies are cleared, so subsequent saves (e.g. a + // model change) must issue zero keychain writes. `migrate_inline_key` + // returns `Nothing` for empty-key records, and `persist_agent_keys_with` + // must propagate that guarantee — write_count stays at 0. + let store = FakeKeyStore::reachable(); + // Records whose inline key is already blank (key lives in the keyring). + let mut records = vec![record_with_key(""), record_with_key("")]; + + persist_agent_keys_with(&store, &mut records); + + assert_eq!( + *store.write_count.borrow(), + 0, + "a save with no inline keys must issue zero keychain writes" + ); +} + +#[test] +fn persist_agent_keys_writes_once_per_record_with_inline_key() { + // A record carrying an inline key (e.g. first save, or keyring-outage + // residue) must trigger exactly one write_and_verify per record — and + // once persisted the inline copy is cleared so the next save is free. + // Records use distinct pubkeys so each maps to a distinct keyring name, + // verifying the "per record" behaviour rather than a single-key overwrite. + let store = FakeKeyStore::reachable(); + let mut records = vec![ + record_with_pubkey_and_key("pubkey-agent-alpha", "nsec1key_a"), + record_with_pubkey_and_key("pubkey-agent-beta", "nsec1key_b"), + ]; + + persist_agent_keys_with(&store, &mut records); + + assert_eq!( + *store.write_count.borrow(), + 2, + "each record with an inline key must trigger exactly one write" + ); + // Verify the correct keyring name was used for each agent. + assert_eq!( + store + .stored + .borrow() + .get(&agent_keyring_name("pubkey-agent-alpha")) + .map(String::as_str), + Some("nsec1key_a"), + ); + assert_eq!( + store + .stored + .borrow() + .get(&agent_keyring_name("pubkey-agent-beta")) + .map(String::as_str), + Some("nsec1key_b"), + ); + // After persist the inline copies are cleared — next save is zero-write. + assert!(records[0].private_key_nsec.is_empty()); + assert!(records[1].private_key_nsec.is_empty()); +} + +fn write_log(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("temp log"); + file.write_all(content.as_bytes()).expect("write log"); + file +} + +/// The keyringless fallback write must land `0o600` from the write itself — +/// not a post-write `chmod` — so a crash in the umask window can never leave +/// plaintext agent nsecs world-readable (Wes storage.rs:239, SECURITY.md:90). +#[cfg(unix)] +#[test] +fn restricted_write_lands_owner_only_without_post_write_chmod() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("managed-agents.json"); + + super::atomic_write_json_restricted(&path, br#"[{"private_key_nsec":"nsec1secret"}]"#) + .expect("restricted write"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "secret-bearing write must be owner-only"); + assert_eq!( + std::fs::read_to_string(&path).expect("read back"), + r#"[{"private_key_nsec":"nsec1secret"}]"# + ); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { + let file = + write_log("noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert!(result.message.contains("llm auth")); + assert_eq!(result.code, Some(-32001)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { + let file = write_log("noise\nllm auth: denied\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm auth: denied"); + assert_eq!(result.code, Some(-32001)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_bare_model_not_found() { + let file = write_log("noise\nllm model not found: (some-model) 404\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!( + result.message, + "Agent reported error: llm model not found: (some-model) 404" + ); + assert_eq!(result.code, Some(-32002)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_legacy_format() { + let file = write_log("noise\nAgent reported error: llm: 500 internal\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm: 500 internal"); + assert_eq!(result.code, None); +} + +#[test] +fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() { + let file = write_log("noise before llm auth: denied\n"); + assert!(super::meaningful_agent_error_from_log(file.path()).is_none()); +} + +#[test] +fn strips_ansi_from_typical_tracing_line() { + let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mbuzz_acp\x1b[0m\x1b[2m:\x1b[0m starting"; + assert_eq!( + strip_ansi_escapes::strip_str(input), + "2026-05-27T15:16:32 INFO buzz_acp: starting" + ); +} + +// ── harness-log selection tests ──────────────────────────────────────── + +const PUBKEY_A: &str = "aa11223344556677889900aabbccddeeff00112233445566778899aabbccddee"; +const PUBKEY_B: &str = "bb11223344556677889900aabbccddeeff00112233445566778899aabbccddee"; + +/// Write `name` into `dir` and stamp it `age_secs` before now, so selection +/// order is asserted against explicit mtimes rather than write order. +fn write_log_in(dir: &Path, name: &str, age_secs: u64) { + let path = dir.join(name); + let file = File::create(&path).expect("create log"); + file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs)) + .expect("stamp mtime"); +} + +#[test] +fn newest_agent_log_prefers_pair_scoped_when_it_is_freshest() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}.log"), 600); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 5); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))) + ); +} + +#[test] +fn newest_agent_log_prefers_legacy_when_it_is_freshest() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}.log"), 5); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 600); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}.log"))), + "mtime decides, not the filename shape" + ); +} + +#[test] +fn newest_agent_log_finds_sole_pair_scoped_log() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 5); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))) + ); +} + +#[test] +fn newest_agent_log_picks_freshest_of_several_relays() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}__aaa.log"), 900); + write_log_in(dir.path(), &format!("{PUBKEY_A}__bbb.log"), 5); + write_log_in(dir.path(), &format!("{PUBKEY_A}__ccc.log"), 300); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__bbb.log"))) + ); +} + +#[test] +fn newest_agent_log_ignores_other_agents_and_non_log_files() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_B}__cafe.log"), 1); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log.gz"), 2); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 600); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))), + "a fresher log belonging to another agent must never be selected" + ); +} + +#[test] +fn newest_agent_log_is_none_when_agent_has_no_logs() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_B}.log"), 1); + + assert_eq!(super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), None); +} + +#[test] +fn newest_agent_log_is_none_when_dir_is_missing() { + let dir = tempfile::tempdir().expect("temp dir"); + let missing = dir.path().join("absent"); + + assert_eq!(super::newest_agent_log_in_dir(&missing, PUBKEY_A), None); +} + +#[test] +fn newest_agent_log_breaks_mtime_ties_deterministically() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}__aaa.log"), 60); + write_log_in(dir.path(), &format!("{PUBKEY_A}__bbb.log"), 60); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__bbb.log"))), + "equal mtimes must resolve to the same file on every read_dir order" + ); +} + +// ── keyring-dev-migration tests ──────────────────────────────────────── + +#[test] +fn copy_agent_keys_copies_keys_present_in_src_to_dst() { + // Keys in src but not in dst must be copied in a single bulk write, + // and the migration-complete marker must be set. + let src = FakeKeyStore::reachable() + .with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha") + .with_key(&agent_keyring_name("agent-beta"), "nsec1beta"); + let dst = FakeKeyStore::reachable(); + + super::copy_agent_keys_between_stores( + &["agent-alpha".to_string(), "agent-beta".to_string()], + &src, + &dst, + ); + + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-alpha")) + .map(String::as_str), + Some("nsec1alpha"), + "agent-alpha must be copied from src to dst" + ); + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-beta")) + .map(String::as_str), + Some("nsec1beta"), + "agent-beta must be copied from src to dst" + ); + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "migration-complete marker must be set after first migration" + ); + // Bulk write: exactly 1 store_all call. + assert_eq!( + *dst.write_count.borrow(), + 1, + "must perform exactly one bulk write" + ); + // Src accessed exactly once (bulk blob read). + assert_eq!( + *src.read_count.borrow(), + 1, + "src must be read exactly once (bulk)" + ); +} + +#[test] +fn copy_agent_keys_skips_keys_already_in_dst() { + // Idempotency: a key already present in dst must NOT be overwritten + // — the agent may have rotated their key in the dev service. + let src = FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1old"); + let dst = FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1new"); + + super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); + + // dst value must remain unchanged — src must not overwrite it. + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-alpha")) + .map(String::as_str), + Some("nsec1new"), + "key already in dst must not be overwritten by migration" + ); + // Marker must still be written even though no new keys were copied. + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "marker must be set even when all keys are already present" + ); + assert_eq!(*src.read_count.borrow(), 0); +} + +#[test] +fn copy_agent_keys_skips_keys_absent_from_src() { + // A pubkey with no entry in src (new agent that will mint a fresh key) + // must be silently skipped — no agent key written to dst. + let src = FakeKeyStore::reachable(); // empty + let dst = FakeKeyStore::reachable(); + + super::copy_agent_keys_between_stores(&["new-agent".to_string()], &src, &dst); + + assert!( + dst.stored + .borrow() + .get(&agent_keyring_name("new-agent")) + .is_none(), + "absent src key must produce no agent key write to dst" + ); + // Marker must still be written. + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "marker must be set even when no keys were present in src" + ); +} + +#[test] +fn copy_agent_keys_skips_all_when_dst_unreachable() { + // When dst keyring is unreachable the migration must be a no-op — never + // data-loss (failing to write is fine; the agent will re-mint on next + // onboarding run). + let src = FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha"); + let dst = FakeKeyStore::unreachable(); + + super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); + + // No writes attempted to an unreachable dst. + assert_eq!(*dst.write_count.borrow(), 0); + // Src must not have been accessed (failed on dst read, returned early). + assert_eq!( + *src.read_count.borrow(), + 0, + "src must not be accessed when dst is unreachable" + ); +} + +#[test] +fn copy_agent_keys_skips_entirely_when_marker_present() { + // After the first migration, the marker is in dst. Subsequent calls + // must return immediately — the prod keyring (src) must never be read. + let src = FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha"); + let dst = FakeKeyStore::reachable() + .with_key(super::DEV_MIGRATION_MARKER, "done") + .with_key(&agent_keyring_name("agent-alpha"), "nsec1dev"); + + super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); + + // Src must not have been accessed at all. + assert_eq!( + *src.read_count.borrow(), + 0, + "src must not be read when migration-complete marker is present" + ); + // Dst must not have been written. + assert_eq!( + *dst.write_count.borrow(), + 0, + "dst must not be written when migration-complete marker is present" + ); + // Dev key must remain unchanged. + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-alpha")) + .map(String::as_str), + Some("nsec1dev"), + "dev key must not be overwritten on subsequent boots" + ); +} + +#[test] +fn copy_agent_keys_writes_marker_even_with_empty_agent_list() { + // An empty pubkey list (no agents yet) must still write the marker so + // future boots skip the prod read. + let src = FakeKeyStore::reachable(); + let dst = FakeKeyStore::reachable(); + + super::copy_agent_keys_between_stores(&[], &src, &dst); + + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "marker must be set even when pubkey list is empty" + ); + assert_eq!(*src.read_count.borrow(), 0); +} + +#[test] +fn try_delete_agent_key_returns_result() { + // Verify the result-returning seam exists and has the correct signature. + // We cannot call it in default builds (system-keyring feature is on, + // which accesses the real OS keychain and blocks in headless/CI). The + // real keychain paths are integration-tested through the #[ignore] + // tests in secret_store.rs; the rollback aggregation is tested in + // team_snapshot::tests::rollback_aggregates_multiple_errors. + let _: fn(&str) -> Result<(), String> = super::try_delete_agent_key; +} diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index d88a362723b..96082acc76d 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -301,9 +301,11 @@ mod tests { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: Some("SENTINEL_SOURCE_TEAM".to_string()), // MUST NOT appear source_team_persona_slug: Some("SENTINEL_SLUG".to_string()), // MUST NOT appear definition_respond_to: None, + catalog_source: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 140ac3cab96..1ffa60eda97 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -208,8 +208,10 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index dcb8095a7cf..3d8e0ed02ba 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -40,6 +40,13 @@ pub struct AgentDefinition { pub is_builtin: bool, #[serde(default = "default_record_active")] pub is_active: bool, + /// Whether this persona is discoverable in the currently active community. + /// + /// This is a command/view projection only. Durable share state lives in + /// the relay+owner-scoped retention head so one workspace's choice cannot + /// leak into another workspace's definition record. + #[serde(default)] + pub shared: bool, /// Team ID if this persona was imported from a team directory. /// Team personas are non-editable (system_prompt, model locked). #[serde( @@ -57,6 +64,13 @@ pub struct AgentDefinition { alias = "source_pack_persona_slug" )] pub source_team_persona_slug: Option, + /// Provenance of a persona copied from another owner's shared catalog. + /// + /// Set only on the copy, never on the original. It is what makes + /// "already added" answerable for a foreign catalog entry: the copy carries + /// a new local id, so the only link back to the publication is this pair. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_source: Option, /// Harness-level configuration passed to the agent subprocess as environment variables. /// Opaque to Buzz — keys and values are runtime-specific. /// @@ -130,8 +144,11 @@ impl AgentDefinition { name_pool: self.name_pool, is_builtin: self.is_builtin, is_active: self.is_active, + // Catalog visibility is relay+owner scoped, not definition-global. + shared: false, source_team: self.source_team, source_team_persona_slug: self.source_team_persona_slug, + catalog_source: self.catalog_source, definition_respond_to: self.respond_to, definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, @@ -161,8 +178,11 @@ impl ManagedAgentRecord { name_pool: self.name_pool.clone(), is_builtin: self.is_builtin, is_active: self.is_active, + // Projected by `list_personas` from the active retention scope. + shared: false, source_team: self.source_team.clone(), source_team_persona_slug: self.source_team_persona_slug.clone(), + catalog_source: self.catalog_source.clone(), env_vars: self.env_vars.clone(), respond_to: self.definition_respond_to.clone(), respond_to_allowlist: self.definition_respond_to_allowlist.clone(), @@ -368,6 +388,13 @@ pub struct ManagedAgentRecord { /// definition hidden from pickers. Defaults `true` for existing records. #[serde(default = "default_record_active")] pub is_active: bool, + /// Legacy process-global catalog visibility field. + /// + /// New writes omit it and definition views ignore it. It remains + /// deserializable for branch-era stores, but active visibility is projected + /// from the relay+owner-scoped retention database instead. + #[serde(default, skip_serializing)] + pub shared: bool, /// Absorbed from `AgentDefinition.source_team` — team ID when this /// definition was imported from a team directory (team definitions are /// non-editable). Distinct from `persona_team_dir`/`persona_name_in_team`, @@ -378,6 +405,10 @@ pub struct ManagedAgentRecord { /// definition's slug within its source team. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_team_persona_slug: Option, + /// Absorbed from `AgentDefinition.catalog_source` — the publication this + /// definition was copied from, when it came from another owner's catalog. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_source: Option, /// NIP-AP definition-level behavioral defaults, absorbed from /// `AgentDefinition` in WIRE shape (kebab-case string / optional u32), /// distinct from the instance-side `respond_to`/`respond_to_allowlist`/ @@ -954,6 +985,8 @@ pub fn resolve_mint_behavioral_defaults( }) } +mod catalog_source; +pub use catalog_source::CatalogSource; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/catalog_source.rs b/desktop/src-tauri/src/managed_agents/types/catalog_source.rs new file mode 100644 index 00000000000..237ffbbfe9b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/catalog_source.rs @@ -0,0 +1,52 @@ +//! The catalog-provenance coordinate carried on a copied persona +//! definition, split from `types.rs` (file-size cap). + +use serde::{Deserialize, Serialize}; + +/// Where a persona copy came from in another owner's shared catalog. +/// +/// The pair is the publication's NIP-AP coordinate minus the kind: the owner +/// who published it and the `d`-tag identifying the persona within that +/// owner's catalog. A copy carries a fresh local `id`, so this pair is the +/// only thing that can answer "is this catalog entry already added". +/// +/// Field casing follows [`super::RelayMeshConfig`]: persisted records use snake_case +/// and the camelCase `alias`es accept the create payload the frontend sends +/// (`rename_all` on the request does not recurse into nested structs). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CatalogSource { + #[serde(alias = "ownerPubkey")] + pub owner_pubkey: String, + #[serde(alias = "personaId")] + pub persona_id: String, +} + +impl CatalogSource { + /// Normalize a coordinate arriving from the frontend. + /// + /// "Already added" is decided by comparing this pair against a + /// publication's author and `d`-tag, so an un-normalized value silently + /// fails to match and mints another copy — the exact duplicate the field + /// exists to prevent. Owner pubkey: 64 hex, any case in, lowercase out + /// (same contract as [`super::validate_respond_to_allowlist`]). Persona id: the + /// publication's `d`-tag, trimmed and required. + pub fn normalized(self) -> Result { + let owner_pubkey = self.owner_pubkey.trim().to_ascii_lowercase(); + if owner_pubkey.len() != 64 || !owner_pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "invalid catalog source owner pubkey: '{owner_pubkey}' (must be 64 hex chars)" + )); + } + let persona_id = self.persona_id.trim().to_string(); + if persona_id.is_empty() { + return Err("catalog source persona id is required".to_string()); + } + Ok(Self { + owner_pubkey, + persona_id, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/types/catalog_source/tests.rs b/desktop/src-tauri/src/managed_agents/types/catalog_source/tests.rs new file mode 100644 index 00000000000..1cdb891c0ab --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/catalog_source/tests.rs @@ -0,0 +1,62 @@ +use super::CatalogSource; + +fn source(owner_pubkey: &str, persona_id: &str) -> CatalogSource { + CatalogSource { + owner_pubkey: owner_pubkey.to_string(), + persona_id: persona_id.to_string(), + } +} + +#[test] +fn normalized_lowercases_and_trims_the_owner_pubkey() { + // "Already added" compares this against a publication's author hex, which + // is always lowercase — a mixed-case value from the UI must not miss. + let normalized = source(&format!(" {} ", "A".repeat(64)), " helper ") + .normalized() + .expect("64 hex chars with surrounding space is valid"); + assert_eq!(normalized.owner_pubkey, "a".repeat(64)); + assert_eq!(normalized.persona_id, "helper"); +} + +#[test] +fn normalized_rejects_a_short_owner_pubkey() { + let err = source("abc123", "helper").normalized().unwrap_err(); + assert!(err.contains("64 hex"), "error must name the rule: {err}"); +} + +#[test] +fn normalized_rejects_a_non_hex_owner_pubkey() { + let err = source(&"z".repeat(64), "helper").normalized().unwrap_err(); + assert!(err.contains("64 hex"), "error must name the rule: {err}"); +} + +#[test] +fn normalized_rejects_a_blank_persona_id() { + let err = source(&"a".repeat(64), " ").normalized().unwrap_err(); + assert!( + err.contains("persona id"), + "error must name the field: {err}" + ); +} + +#[test] +fn deserializes_the_camel_case_payload_the_frontend_sends() { + // `rename_all` on CreatePersonaRequest does not recurse into this struct, + // so without the aliases the copy request fails at the Tauri boundary. + let parsed: CatalogSource = + serde_json::from_str(r#"{"ownerPubkey":"abc","personaId":"helper"}"#) + .expect("camelCase payload from TS should deserialize"); + assert_eq!(parsed, source("abc", "helper")); +} + +#[test] +fn round_trips_persisted_snake_case() { + let value = source(&"a".repeat(64), "helper"); + let json = serde_json::to_string(&value).unwrap(); + assert!(json.contains("owner_pubkey"), "persisted shape: {json}"); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + value, + "the camelCase alias must not break the stored-record round trip" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 58d60218a13..e28b0bd461a 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use super::{ default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, BackendKind, - RelayMeshConfig, RespondTo, + CatalogSource, RelayMeshConfig, RespondTo, }; /// The NIP-AP behavioral group as one grouped request field. @@ -91,6 +91,10 @@ pub struct CreatePersonaRequest { /// NIP-AP behavioral group. Absent = behavior group stays unset. #[serde(default)] pub behavior: Option, + /// Set when this persona is a copy of another owner's shared catalog entry, + /// so the catalog can tell an already-added foreign persona from a new one. + #[serde(default)] + pub catalog_source: Option, } #[derive(Debug, Deserialize)] @@ -275,8 +279,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -428,4 +434,37 @@ mod tests { .unwrap(); assert_eq!(record.parallelism, Some(8)); } + + /// The catalog copy path is the only caller that sends this field, and it + /// sends camelCase from TS. Without it deserializing, the copy silently + /// lands with no provenance and duplicate-add returns. + #[test] + fn create_request_deserializes_camel_case_catalog_source() { + let request: CreatePersonaRequest = serde_json::from_str( + r#"{ + "displayName": "Copy", + "avatarUrl": null, + "systemPrompt": "Prompt", + "catalogSource": { "ownerPubkey": "abc", "personaId": "helper" } + }"#, + ) + .expect("camelCase catalogSource payload from TS should deserialize"); + assert_eq!( + request.catalog_source, + Some(CatalogSource { + owner_pubkey: "abc".to_string(), + persona_id: "helper".to_string(), + }) + ); + } + + /// Ordinary agent creation never sends the field. + #[test] + fn create_request_without_catalog_source_is_not_a_catalog_copy() { + let request: CreatePersonaRequest = serde_json::from_str( + r#"{ "displayName": "Fresh", "avatarUrl": null, "systemPrompt": "Prompt" }"#, + ) + .expect("a create payload without provenance should deserialize"); + assert_eq!(request.catalog_source, None); + } } diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 667a41a538a..96ed5560689 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -1,4 +1,4 @@ -use super::{AgentDefinition, ManagedAgentRecord}; +use super::{AgentDefinition, CatalogSource, ManagedAgentRecord}; use std::path::PathBuf; #[test] @@ -482,8 +482,10 @@ fn sample_persona() -> AgentDefinition { name_pool: vec!["Nimble".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: Some("team-1".to_string()), source_team_persona_slug: Some("helper".to_string()), + catalog_source: None, env_vars: [("K".to_string(), "v".to_string())].into_iter().collect(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -493,6 +495,49 @@ fn sample_persona() -> AgentDefinition { } } +#[test] +fn persona_record_without_catalog_source_deserializes_and_omits_it() { + // Every persona already on disk predates the field — an old record must + // load as "not a catalog copy" and must not gain a null key on save. + let record: AgentDefinition = serde_json::from_str( + r#"{ + "id": "persona-1", + "display_name": "Test", + "avatar_url": null, + "system_prompt": "Prompt", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("pre-catalog-source persona should deserialize"); + + assert_eq!(record.catalog_source, None); + let json = serde_json::to_string(&record).unwrap(); + assert!( + !json.contains("catalog_source"), + "absent provenance must stay absent on disk: {json}" + ); +} + +#[test] +fn persona_catalog_source_survives_the_agent_store_fold() { + // Provenance is only useful if it is still there on the next launch, and + // `save_personas` funnels every definition through `into_agent_record`. + let mut persona = sample_persona(); + persona.catalog_source = Some(CatalogSource { + owner_pubkey: "a".repeat(64), + persona_id: "helper".to_string(), + }); + + let view = persona + .clone() + .into_agent_record() + .to_definition_view() + .expect("slugged record must present a persona view"); + + assert_eq!(view.catalog_source, persona.catalog_source); +} + #[test] fn persona_into_agent_record_is_keyless_and_slugged() { let record = sample_persona().into_agent_record(); diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index ce6d495a472..809fab89933 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -412,6 +412,7 @@ mod tests { is_active: true, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::from([ ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), ( diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 2a93c00185b..39dfc988ddf 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -35,8 +35,10 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati name_pool: vec!["Fizzy".to_string()], is_builtin: true, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095af..f8966956241 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -532,49 +532,9 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── mod submit; -pub use submit::{submit_event, submit_event_at_with_keys, SubmitEventResponse}; - -/// POST an already-signed event to `/events` with NIP-98 auth. -/// -/// The persona flush loop drains pre-signed events from the retention store, -/// so it must publish them verbatim — re-signing through `submit_event` would -/// mint a new `created_at`/signature and break the compare-and-clear that -/// `mark_synced` relies on. Only the NIP-98 request auth is signed here (with -/// the owner keys), and that lock is dropped before the `.await`. -pub async fn submit_signed_event( - event: &nostr::Event, - state: &AppState, -) -> Result { - crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/events", relay_api_base_url_with_override(state)); - let body_bytes = event.as_json().into_bytes(); - let auth_header = { - let keys = state.signing_keys()?; - build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)? - }; // keys dropped here - - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - let result: SubmitEventResponse = parse_json_response(response).await?; - - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } - - Ok(result) -} +pub use submit::{ + submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse, +}; /// Sign an event with explicit keys and POST it to `/events` with NIP-98 auth. /// diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 7fb3f94041d..2a42d86c2b1 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -8,22 +8,22 @@ pub struct SubmitEventResponse { pub message: String, } -/// Sign with an explicit identity and POST the event to an explicit relay. +/// POST an already-signed event to an explicit relay with an explicit owner. /// -/// The caller owns the signer lifetime. This is important for deferred work: -/// an in-process identity swap cannot retarget the event or its NIP-98 auth -/// after the caller has validated which identity the operation belongs to. -pub async fn submit_event_at_with_keys( - builder: nostr::EventBuilder, +/// Deferred/scoped publication uses this form so a workspace or identity +/// switch cannot retarget either the event or its NIP-98 authentication after +/// the operation captured its `(relay, owner)` scope. +pub async fn submit_signed_event_at_with_keys( + event: &nostr::Event, state: &AppState, api_base_url: &str, keys: &nostr::Keys, ) -> Result { + if event.pubkey != keys.public_key() { + return Err("signed event does not match the publishing identity".to_string()); + } crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", api_base_url.trim_end_matches('/')); - let event = builder - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event: {e}"))?; let body_bytes = event.as_json().into_bytes(); let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; @@ -49,6 +49,23 @@ pub async fn submit_event_at_with_keys( Ok(result) } +/// Sign with an explicit identity and POST the event to an explicit relay. +/// +/// The caller owns the signer lifetime. This is important for deferred work: +/// an in-process identity swap cannot retarget the event or its NIP-98 auth +/// after the caller has validated which identity the operation belongs to. +pub async fn submit_event_at_with_keys( + builder: nostr::EventBuilder, + state: &AppState, + api_base_url: &str, + keys: &nostr::Keys, +) -> Result { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + submit_signed_event_at_with_keys(&event, state, api_base_url, keys).await +} + /// Build and submit an event to the currently active workspace relay. pub async fn submit_event( builder: nostr::EventBuilder, diff --git a/desktop/src-tauri/src/webkit_rendering.rs b/desktop/src-tauri/src/webkit_rendering.rs new file mode 100644 index 00000000000..905da5eeed8 --- /dev/null +++ b/desktop/src-tauri/src/webkit_rendering.rs @@ -0,0 +1,208 @@ +//! WebKit rendering workarounds for Linux, applied before WebKit initializes. +//! +//! WebKitGTK's dmabuf renderer aborts the web process during startup on some +//! GPU/driver/compositor combinations, so Buzz comes up with no window at all +//! and the user has no way to fix it (#2338, upstream tauri#9394). Setting +//! `WEBKIT_DISABLE_DMABUF_RENDERER=1` avoids the abort by falling back to the +//! shared-memory buffer path. +//! +//! WebKit reads each of these variables exactly once per process, so the choice +//! has to be made before anything initializes — there is no runtime toggle and +//! no second chance later in the same process. This module therefore decides +//! from cheap preflight signals instead of reacting to a crash: +//! +//! * an NVIDIA GPU, the driver family behind most upstream reports; and +//! * AppImage packaging, where linuxdeploy's AppRun hook pins `GDK_BACKEND=x11` +//! and the dmabuf renderer buys nothing on that XWayland path (#2338). +//! +//! `--safe-rendering` is the manual escape hatch for a machine neither signal +//! recognises; it also disables accelerated compositing, for that launch only. +//! +//! This is the shape the Tauri ecosystem converged on: clash-verge-rev's +//! `utils/linux/workarounds.rs` and screenpipe's `linux_webkit_env.rs` both set +//! the same variable from the same signals at the same point in startup. + +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +/// Force the safest rendering configuration for this launch. +const SAFE_RENDERING: &str = "--safe-rendering"; + +/// PCI vendor ID reported by NVIDIA devices under `/sys/class/drm`. +const NVIDIA_PCI_VENDOR: &str = "0x10de"; + +/// Where DRM devices advertise their PCI vendor. +const DRM_ROOT: &str = "/sys/class/drm"; + +/// Drops the zero-copy dmabuf buffer path. The workaround for #2338. +const DISABLE_DMABUF: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; +/// Drops accelerated compositing as well. `--safe-rendering` only. +const DISABLE_COMPOSITING: &str = "WEBKIT_DISABLE_COMPOSITING_MODE"; + +/// What the heuristic applies: the #2338 workaround alone, matching the +/// ecosystem precedents. `DISABLE_COMPOSITING` is deliberately not here — no +/// report has isolated it as necessary, and it costs more rendering than this. +const HEURISTIC: [&str; 1] = [DISABLE_DMABUF]; + +/// What `--safe-rendering` applies, which is also every variable this module may +/// set and therefore every variable a user assignment takes away from it. Being +/// the same list is the invariant: nothing outside it is ever written, so a user +/// value for any other WebKit variable is not a conflict. +const OWNED: [&str; 2] = [DISABLE_DMABUF, DISABLE_COMPOSITING]; + +/// Reads one environment variable. Injected so the decision is testable without +/// mutating the process environment. `OsString` rather than `String` because +/// presence is the test — a non-UTF-8 assignment is still the user's. +type EnvLookup<'a> = &'a dyn Fn(&str) -> Option; + +/// What this launch should do about its rendering environment. +#[derive(Debug, PartialEq, Eq)] +enum Plan { + /// Set each of these to `1`, then report `why`. + Apply { + vars: &'static [&'static str], + why: String, + }, + /// Change nothing, and report `why`. + Leave { why: String }, + /// The request cannot be delivered. Report it and exit non-zero rather than + /// starting an app that silently ignores what the user asked for. + Fatal { diagnostic: String }, +} + +/// Applies the workaround for this launch. +/// +/// Must be called from `main()` before `crate::run()`: WebKit memoizes these +/// variables at process start, and `std::env::set_var` is only sound while the +/// process is still single threaded, which it is nowhere else in Buzz. +/// +/// `Err` carries a user-facing diagnostic; the caller reports it and exits. +pub fn apply() -> Result<(), String> { + match plan( + std::env::args_os(), + &|key| std::env::var_os(key), + Path::new(DRM_ROOT), + ) { + Plan::Apply { vars, why } => { + for var in vars { + // Safe here and only here — see the doc comment above. + std::env::set_var(var, "1"); + } + let applied: Vec = vars.iter().map(|var| format!("{var}=1")).collect(); + eprintln!("buzz-desktop: {} — {why}", applied.join(" ")); + Ok(()) + } + Plan::Leave { why } => { + eprintln!("buzz-desktop: WebKit rendering left as-is — {why}"); + Ok(()) + } + Plan::Fatal { diagnostic } => Err(diagnostic), + } +} + +/// The whole decision, as a pure function of argv, the environment, and the DRM +/// device tree. +fn plan( + args: impl IntoIterator>, + env: EnvLookup<'_>, + drm_root: &Path, +) -> Plan { + let safe_rendering = args + .into_iter() + .any(|arg| arg.as_ref() == OsStr::new(SAFE_RENDERING)); + let user_set = user_set(env); + + if !user_set.is_empty() { + // A user who has assigned one of these has taken over the decision, so + // the heuristic stands down wholesale — writing the *other* variable + // behind their back would be exactly the surprise they opted out of. + return match safe_rendering { + // Two incompatible answers to one question, and no basis for + // picking: honouring the flag would overwrite configuration the + // user typed, honouring the environment would silently ignore a + // rescue flag from a user whose app does not start. + true => Plan::Fatal { + diagnostic: conflict(&user_set), + }, + false => Plan::Leave { + why: format!("{} set in the environment", describe(&user_set)), + }, + }; + } + + if safe_rendering { + return Plan::Apply { + vars: &OWNED, + why: format!("{SAFE_RENDERING} requested, this launch only"), + }; + } + + let signals = [ + (nvidia_gpu(drm_root), "NVIDIA GPU"), + (env("APPIMAGE").is_some(), "AppImage"), + ]; + let hits: Vec<&str> = signals + .iter() + .filter_map(|(hit, label)| hit.then_some(*label)) + .collect(); + + match hits.is_empty() { + true => Plan::Leave { + why: "no NVIDIA GPU and not an AppImage".to_string(), + }, + false => Plan::Apply { + vars: &HEURISTIC, + why: hits.join(", "), + }, + } +} + +/// Owned variables the environment already carries, keyed by name. +/// +/// Presence is the test, not truthiness: `VAR=0` and `VAR=` are both genuine +/// user assignments, and both take the decision away from this module. +fn user_set(env: EnvLookup<'_>) -> Vec<(&'static str, OsString)> { + OWNED + .iter() + .filter_map(|key| env(key).map(|value| (*key, value))) + .collect() +} + +/// User assignments rendered as `KEY=value`, for a log line or a diagnostic. +fn describe(user_set: &[(&str, OsString)]) -> String { + let shown: Vec = user_set + .iter() + .map(|(key, value)| format!("{key}={}", value.to_string_lossy())) + .collect(); + shown.join(", ") +} + +/// Whether any DRM device reports NVIDIA's PCI vendor ID. An unreadable device +/// tree is not a hit — the workaround has a real cost, so it needs evidence. +fn nvidia_gpu(drm_root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(drm_root) else { + return false; + }; + entries.flatten().any(|entry| { + std::fs::read_to_string(entry.path().join("device/vendor")) + .is_ok_and(|vendor| vendor.trim().eq_ignore_ascii_case(NVIDIA_PCI_VENDOR)) + }) +} + +/// The diagnostic for `--safe-rendering` against a user-set owned variable. +/// +/// The message both shows what is set and names the keys to unset — the two +/// things a user whose app will not start needs in order to act on it. +fn conflict(user_set: &[(&str, OsString)]) -> String { + let keys: Vec<&str> = user_set.iter().map(|(key, _)| *key).collect(); + format!( + "{SAFE_RENDERING} cannot be applied: {} already set in the environment. \ + Either unset {} and run {SAFE_RENDERING} again, or keep that \ + environment and drop the flag.", + describe(user_set), + keys.join(", "), + ) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/webkit_rendering/tests.rs b/desktop/src-tauri/src/webkit_rendering/tests.rs new file mode 100644 index 00000000000..5be1612b216 --- /dev/null +++ b/desktop/src-tauri/src/webkit_rendering/tests.rs @@ -0,0 +1,250 @@ +//! Behaviour of the preflight decision. +//! +//! Every case goes through `plan`, which takes argv, the environment, and the +//! DRM root as arguments — so nothing here mutates the process environment and +//! the tests are order-independent. + +use super::*; + +const NO_ARGS: [&str; 0] = []; + +/// A `/sys/class/drm` stand-in. `vendors` are written as `card/device/vendor` +/// with the trailing newline the kernel emits. +fn drm(vendors: &[&str]) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("tempdir"); + for (index, vendor) in vendors.iter().enumerate() { + let device = root.path().join(format!("card{index}")).join("device"); + std::fs::create_dir_all(&device).expect("device dir"); + std::fs::write(device.join("vendor"), format!("{vendor}\n")).expect("vendor"); + } + root +} + +fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let owned: Vec<(String, OsString)> = pairs + .iter() + .map(|(key, value)| (key.to_string(), OsString::from(value))) + .collect(); + move |key| { + owned + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value.clone()) + } +} + +/// The variables a plan would set, or `None` for a plan that sets nothing. +fn applied(plan: &Plan) -> Option<&[&str]> { + match plan { + Plan::Apply { vars, .. } => Some(vars), + _ => None, + } +} + +// ── Detection ─────────────────────────────────────────────────────────────── + +#[test] +fn test_nvidia_gpu_disables_the_dmabuf_renderer() { + let drm = drm(&["0x10de"]); + let plan = plan(NO_ARGS, &env_from(&[]), drm.path()); + + assert_eq!( + applied(&plan), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); + let Plan::Apply { why, .. } = &plan else { + unreachable!() + }; + assert!(why.contains("NVIDIA"), "{why}"); +} + +#[test] +fn test_an_nvidia_gpu_alongside_another_vendor_still_counts() { + // Hybrid graphics: the integrated GPU enumerates first, and WebKit may + // still land on the discrete one. + let drm = drm(&["0x8086", "0x10de"]); + + assert_eq!( + applied(&plan(NO_ARGS, &env_from(&[]), drm.path())), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); +} + +#[test] +fn test_the_vendor_id_match_ignores_case() { + let drm = drm(&["0x10DE"]); + + assert_eq!( + applied(&plan(NO_ARGS, &env_from(&[]), drm.path())), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); +} + +#[test] +fn test_an_appimage_launch_disables_the_dmabuf_renderer() { + // No NVIDIA GPU: the AppImage signal has to carry this on its own, which is + // #2338's reporter (Intel Mesa under the AppRun's pinned XWayland backend). + let drm = drm(&["0x8086"]); + let env = env_from(&[("APPIMAGE", "/home/u/Buzz.AppImage")]); + let plan = plan(NO_ARGS, &env, drm.path()); + + assert_eq!( + applied(&plan), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); + let Plan::Apply { why, .. } = &plan else { + unreachable!() + }; + assert!(why.contains("AppImage"), "{why}"); +} + +#[test] +fn test_a_plain_non_nvidia_launch_changes_nothing() { + let drm = drm(&["0x8086", "0x1002"]); + + assert!(matches!( + plan(NO_ARGS, &env_from(&[]), drm.path()), + Plan::Leave { .. } + )); +} + +#[test] +fn test_an_unreadable_drm_tree_is_not_treated_as_a_hit() { + // Containers and hardened kernels can hide `/sys/class/drm` entirely. The + // workaround costs real rendering performance, so absent evidence is not + // evidence — this must not become an unconditional export. + let missing = std::path::Path::new("/nonexistent/class/drm"); + + assert!(matches!( + plan(NO_ARGS, &env_from(&[]), missing), + Plan::Leave { .. } + )); +} + +#[test] +fn test_a_device_without_a_vendor_file_is_skipped_not_fatal() { + // `/sys/class/drm` also contains connector entries (`card0-HDMI-A-1`) and + // `renderD*` nodes, which have no `device/vendor` under them. + let root = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(root.path().join("card0-HDMI-A-1")).expect("connector"); + let device = root.path().join("card1").join("device"); + std::fs::create_dir_all(&device).expect("device dir"); + std::fs::write(device.join("vendor"), "0x10de\n").expect("vendor"); + + assert_eq!( + applied(&plan(NO_ARGS, &env_from(&[]), root.path())), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); +} + +// ── User environment ──────────────────────────────────────────────────────── + +#[test] +fn test_a_user_set_variable_disables_the_heuristic_wholesale() { + // `0` is the value a truthiness check would drop: the user is asking for the + // dmabuf renderer *on*, on a machine the heuristic would have opted out. + let drm = drm(&["0x10de"]); + let env = env_from(&[(DISABLE_DMABUF, "0")]); + let plan = plan(NO_ARGS, &env, drm.path()); + + let Plan::Leave { why } = &plan else { + panic!("a user assignment must not be overwritten: {plan:?}"); + }; + assert!(why.contains("WEBKIT_DISABLE_DMABUF_RENDERER=0"), "{why}"); +} + +#[test] +fn test_an_empty_assignment_is_still_a_user_assignment() { + let drm = drm(&["0x10de"]); + let env = env_from(&[(DISABLE_DMABUF, "")]); + + assert!(matches!( + plan(NO_ARGS, &env, drm.path()), + Plan::Leave { .. } + )); +} + +#[test] +fn test_a_user_set_compositing_variable_also_stands_the_heuristic_down() { + // The heuristic never sets this one, but it is still ours to set under + // `--safe-rendering`, so a user value takes the whole decision away rather + // than leaving us free to write the sibling variable. + let drm = drm(&["0x10de"]); + let env = env_from(&[(DISABLE_COMPOSITING, "1")]); + + assert!(matches!( + plan(NO_ARGS, &env, drm.path()), + Plan::Leave { .. } + )); +} + +// ── --safe-rendering ──────────────────────────────────────────────────────── + +#[test] +fn test_safe_rendering_applies_the_safest_set_without_any_hardware_signal() { + // The escape hatch exists for the machine neither signal recognises, so it + // must not depend on either one. + let drm = drm(&["0x8086"]); + let args = ["buzz://channel/1", SAFE_RENDERING]; + let plan = plan(args, &env_from(&[]), drm.path()); + + assert_eq!( + applied(&plan), + Some( + &[ + "WEBKIT_DISABLE_DMABUF_RENDERER", + "WEBKIT_DISABLE_COMPOSITING_MODE" + ][..] + ) + ); +} + +#[test] +fn test_an_unrelated_flag_is_not_mistaken_for_safe_rendering() { + let drm = drm(&["0x8086"]); + + assert!(matches!( + plan(["--safe-renderingX"], &env_from(&[]), drm.path()), + Plan::Leave { .. } + )); +} + +#[test] +fn test_safe_rendering_against_a_user_set_variable_is_fatal_not_guessed() { + let drm = drm(&["0x8086"]); + let env = env_from(&[(DISABLE_DMABUF, "0")]); + let plan = plan([SAFE_RENDERING], &env, drm.path()); + + let Plan::Fatal { diagnostic } = &plan else { + panic!("the flag and the environment disagree; neither may be guessed: {plan:?}"); + }; + // The message has to name what is set and what to unset, or the user whose + // app will not start cannot act on it. + assert!(diagnostic.contains(SAFE_RENDERING), "{diagnostic}"); + assert!( + diagnostic.contains("WEBKIT_DISABLE_DMABUF_RENDERER=0"), + "{diagnostic}" + ); +} + +#[test] +fn test_a_non_utf8_user_assignment_is_reported_not_ignored() { + // Presence is the test, so this still stands the heuristic down; the + // diagnostic must name the key rather than dropping the whole entry. + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + let drm = drm(&["0x10de"]); + let invalid = OsString::from_vec(vec![0xff, 0xfe]); + let env = |key: &str| match key == DISABLE_DMABUF { + true => Some(invalid.clone()), + false => None, + }; + + let Plan::Fatal { diagnostic } = plan([SAFE_RENDERING], &env, drm.path()) else { + panic!("a non-UTF-8 assignment is still a user assignment"); + }; + assert!(diagnostic.contains(DISABLE_DMABUF), "{diagnostic}"); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 07b7216346f..85ad5c0b2d7 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.4.26", + "version": "0.5.1", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa9348c..75f57257ccc 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,6 +96,7 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; + const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -105,7 +106,6 @@ export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); - const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); @@ -167,7 +167,10 @@ export function AppShell() { const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, ); - usePersonaSync(identityQuery.data?.pubkey); + usePersonaSync( + identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, + ); useAgentsDataRefresh(); // Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON). useAutoRestartPolicy(); diff --git a/desktop/src/app/useThreadActivityFeedItems.test.mjs b/desktop/src/app/useThreadActivityFeedItems.test.mjs index 6c017028ca4..54d36dc1e88 100644 --- a/desktop/src/app/useThreadActivityFeedItems.test.mjs +++ b/desktop/src/app/useThreadActivityFeedItems.test.mjs @@ -113,3 +113,18 @@ test("channel-presence fence applies before mute filter — unknown channel + mu assert.deepEqual(items, []); }); + +test("top-level DM activity is included in Activity", () => { + const dmItem = threadActivityItem({ + id: "agent-dm-reply", + tags: [["h", CHANNEL_ID]], + }); + const channels = [{ id: CHANNEL_ID, name: "Agent DM", channelType: "dm" }]; + + assert.deepEqual( + buildThreadActivityFeedItems([dmItem], new Set(), channels).map( + (item) => item.id, + ), + ["agent-dm-reply"], + ); +}); diff --git a/desktop/src/app/useThreadActivityFeedItems.ts b/desktop/src/app/useThreadActivityFeedItems.ts index af0c50ce48a..b14eb51327f 100644 --- a/desktop/src/app/useThreadActivityFeedItems.ts +++ b/desktop/src/app/useThreadActivityFeedItems.ts @@ -17,7 +17,8 @@ export function buildThreadActivityFeedItems( // is present in the active community's channel set. Rows persisted under // a different relay's scope key should never reach this function, but // this filter is the last line of defense against cross-community leaks. - if (channelById.get(item.channelId) === undefined) return false; + const channel = channelById.get(item.channelId); + if (channel === undefined) return false; const rootId = getThreadReference(item.tags).rootId; return !rootId || !mutedRootIds.has(rootId); }) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 06e6c02acbb..35ad4a63af5 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -106,6 +106,14 @@ with a TypeScript lookup table or an id comparison in a component. Edit. In Edit, selecting Custom command keeps its required command field beside the harness picker rather than hiding it in Advanced. +10. **Catalog visibility is community-scoped relay state, never a global + definition field.** `AgentDefinition.shared` is only the active + relay+owner projection returned to the UI. Durable heads and pending + publications live in the scoped retention database, and explicit share + toggles await relay acceptance before the UI claims that an agent was + published or removed. A queued update must stay visibly queued, and the + catalog itself must render only relay-confirmed publications — never an + optimistic local persona. ## The tests that enforce this @@ -124,6 +132,8 @@ with a TypeScript lookup table or an id comparison in a component. acceptance coverage for readiness, failure states, defaults, navigation, successful-empty vs failed optional-model discovery, and persistence races. - 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. ## Keep this file true diff --git a/desktop/src/features/agents/assets/agent-outline.svg b/desktop/src/features/agents/assets/agent-outline.svg new file mode 100644 index 00000000000..b89f4c61c93 --- /dev/null +++ b/desktop/src/features/agents/assets/agent-outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/desktop/src/features/agents/knownAgentPubkeys.test.mjs b/desktop/src/features/agents/knownAgentPubkeys.test.mjs index 2b7b3e18afc..d58b7f5dabc 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.test.mjs +++ b/desktop/src/features/agents/knownAgentPubkeys.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mergeKnownAgentPubkeys } from "./knownAgentPubkeys.ts"; +import { + mergeKnownAgentPubkeys, + mergeOwnedAgentPubkeys, +} from "./knownAgentPubkeys.ts"; const MANAGED = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -34,3 +37,26 @@ test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => { assert.deepEqual([...merged], [MANAGED]); }); + +test("owned agents include managed and profile-declared agents", () => { + const merged = mergeOwnedAgentPubkeys( + [{ pubkey: MANAGED }], + { + [RELAY]: { ownerPubkey: " owner " }, + other: { ownerPubkey: "somebody-else" }, + }, + "OWNER", + ); + + assert.deepEqual([...merged].sort(), [MANAGED, RELAY].sort()); +}); + +test("owned agents exclude agents controlled by somebody else", () => { + const merged = mergeOwnedAgentPubkeys( + undefined, + { [RELAY]: { ownerPubkey: "somebody-else" } }, + "owner", + ); + + assert.equal(merged.size, 0); +}); diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts index 978509af096..70cbac68fdb 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.ts +++ b/desktop/src/features/agents/knownAgentPubkeys.ts @@ -22,6 +22,34 @@ export function mergeKnownAgentPubkeys( return pubkeys; } +/** Agent identities controlled by the current user. */ +export function mergeOwnedAgentPubkeys( + managedAgents: readonly { pubkey: string }[] | undefined, + profiles: + | Readonly> + | undefined, + currentPubkey: string | null | undefined, +): ReadonlySet { + const pubkeys = new Set(); + for (const agent of managedAgents ?? []) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + + if (!currentPubkey) return pubkeys; + + const ownerPubkey = normalizePubkey(currentPubkey); + for (const [pubkey, profile] of Object.entries(profiles ?? {})) { + if ( + profile.ownerPubkey && + normalizePubkey(profile.ownerPubkey) === ownerPubkey + ) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + + return pubkeys; +} + /** * Channel-scoped variant: the managed ∪ relay baseline plus this channel's * bot members (role `bot` or `isAgent`), so member-only agents are included. diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 7fa72f4f3ee..62e809bdb5c 100644 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -2,11 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getCatalogPersonas, - getCatalogSelectionState, getLibraryPersonas, getPersonaLabelsById, - getPersonaLibraryState, isCatalogPersonaSelected, } from "./catalog.ts"; @@ -25,62 +22,6 @@ function createPersona(id, displayName, overrides = {}) { }; } -test("getCatalogPersonas keeps built-ins visible whether selected or not", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("custom:builder", "Builder"), - ]; - - assert.deepEqual( - getCatalogPersonas(personas).map((persona) => persona.id), - ["builtin:fizz"], - ); -}); - -test("getCatalogSelectionState keeps built-in selection rules in one place", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getCatalogSelectionState(personas); - - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.deepEqual( - state.selectedCatalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.deepEqual( - state.unselectedCatalogPersonas.map((persona) => persona.id), - [], - ); -}); - -test("getCatalogPersonas keeps chooser order stable when selection changes", () => { - const inactive = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, - isActive: true, - }), - ]; - const active = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, - isActive: false, - }), - ]; - - assert.deepEqual( - getCatalogPersonas(inactive).map((persona) => persona.id), - getCatalogPersonas(active).map((persona) => persona.id), - ); -}); - test("isCatalogPersonaSelected treats active catalog personas as selected", () => { assert.equal( isCatalogPersonaSelected( @@ -118,25 +59,6 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => { }); }); -test("getPersonaLibraryState keeps the working library and full catalog in one place", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getPersonaLibraryState(personas); - - assert.deepEqual( - state.libraryPersonas.map((persona) => persona.id), - ["builtin:fizz", "custom:builder"], - ); - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz"); -}); - test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => { const avatarUrl = "https://example.test/coordinator.png"; const personas = [ diff --git a/desktop/src/features/agents/lib/catalog.ts b/desktop/src/features/agents/lib/catalog.ts index 226aafca5e3..fabc0af87ec 100644 --- a/desktop/src/features/agents/lib/catalog.ts +++ b/desktop/src/features/agents/lib/catalog.ts @@ -1,17 +1,5 @@ import type { AgentPersona } from "@/shared/api/types"; -export type CatalogSelectionState = { - catalogPersonas: AgentPersona[]; - selectedCatalogPersonas: AgentPersona[]; - unselectedCatalogPersonas: AgentPersona[]; -}; - -export type PersonaLibraryState = { - catalogPersonas: AgentPersona[]; - libraryPersonas: AgentPersona[]; - personaLabelsById: Record; -}; - export function isPersonaActive(persona: AgentPersona) { return persona.isActive; } @@ -24,62 +12,12 @@ export function getLibraryPersonas(personas: readonly AgentPersona[]) { return getActivePersonas(personas); } -export function isPersonaVisibleInCatalog( - persona: AgentPersona, - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id); -} - -export function getCatalogPersonas( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return personas - .filter((persona) => - isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds), - ) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); -} - export function isCatalogPersonaSelected(persona: AgentPersona) { return persona.isActive; } -export function getCatalogSelectionState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): CatalogSelectionState { - const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds); - - return { - catalogPersonas, - selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected), - unselectedCatalogPersonas: catalogPersonas.filter( - (persona) => !isCatalogPersonaSelected(persona), - ), - }; -} - export function getPersonaLabelsById(personas: readonly AgentPersona[]) { return Object.fromEntries( personas.map((persona) => [persona.id, persona.displayName]), ); } - -export function getPersonaLibraryState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): PersonaLibraryState { - const libraryPersonas = getLibraryPersonas(personas); - const { catalogPersonas } = getCatalogSelectionState( - personas, - sharedCatalogPersonaIds, - ); - - return { - catalogPersonas, - libraryPersonas, - personaLabelsById: getPersonaLabelsById(personas), - }; -} diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs deleted file mode 100644 index 9439d4a36e0..00000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { clearLegacyPersonaCatalogVisibility } from "./legacyPersonaCatalogVisibility.ts"; - -test("clearLegacyPersonaCatalogVisibility removes the retired preference", () => { - const removedKeys = []; - - clearLegacyPersonaCatalogVisibility({ - removeItem(key) { - removedKeys.push(key); - }, - }); - - assert.deepEqual(removedKeys, ["buzz-persona-catalog-visibility-v1"]); -}); - -test("clearLegacyPersonaCatalogVisibility ignores unavailable storage", () => { - assert.doesNotThrow(() => clearLegacyPersonaCatalogVisibility(null)); - assert.doesNotThrow(() => - clearLegacyPersonaCatalogVisibility({ - removeItem() { - throw new Error("storage unavailable"); - }, - }), - ); -}); diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts deleted file mode 100644 index 38b2d5d9743..00000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts +++ /dev/null @@ -1,28 +0,0 @@ -const LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = - "buzz-persona-catalog-visibility-v1"; - -/** - * Removes the retired custom-persona catalog preference so it cannot resurface - * agents after the visibility control has been removed. - */ -export function clearLegacyPersonaCatalogVisibility( - storage?: Pick | null, -) { - let targetStorage = storage; - if (targetStorage === undefined) { - if (typeof window === "undefined") return; - - try { - targetStorage = window.localStorage; - } catch { - return; - } - } - if (!targetStorage) return; - - try { - targetStorage.removeItem(LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); - } catch { - // Catalog cleanup is best-effort and should not block the agents view. - } -} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs new file mode 100644 index 00000000000..fbaf1f52742 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -0,0 +1,484 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { emojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils.ts"; +import { + catalogPersonasFromPublications, + catalogPublicationsFromEvents, + fetchPersonaCatalogPublications, + personaEventIsShared, +} from "./personaCatalogRelay.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); + +function personaEvent({ + createdAt, + id, + owner = ALICE, + sourcePersonaId = "reviewer", + shared = true, + avatarUrl = null, + respondTo = null, + sharedTag, +}) { + return { + id, + pubkey: owner, + created_at: createdAt, + kind: 30175, + tags: [ + ["d", sourcePersonaId], + ...(shared + ? [sharedTag ?? ["shared", "true"]] + : sharedTag + ? [sharedTag] + : []), + ], + content: JSON.stringify({ + display_name: "Relay Reviewer", + system_prompt: "Review changes.", + avatar_url: avatarUrl, + runtime: "goose", + model: "claude", + provider: null, + name_pool: ["Reviewer"], + respond_to: respondTo, + respond_to_allowlist: respondTo === "allowlist" ? [BOB] : undefined, + parallelism: 4, + }), + sig: "sig", + }; +} + +test("a shared kind 30175 persona from Alice is discoverable by Bob", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const personas = catalogPersonasFromPublications(publications, [], BOB); + + assert.equal(personas.length, 1); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].isActive, false); + assert.equal(personas[0].shared, true); + assert.equal(personas[0].catalogSource.ownerPubkey, ALICE); + assert.equal(personas[0].catalogSource.isOwn, false); +}); + +test("a newer unshared head hides the older shared head", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "shared" }), + personaEvent({ createdAt: 2, id: "unshared", shared: false }), + ]); + + assert.deepEqual(publications, []); +}); + +test("persona coordinates remain independent across authors", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice", owner: ALICE }), + personaEvent({ createdAt: 1, id: "bob", owner: BOB }), + ]); + + assert.equal(publications.length, 2); + assert.equal( + catalogPersonasFromPublications(publications, [], BOB).length, + 2, + ); +}); + +test("equal-second persona heads use the relay lowest-id tie-break", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "b".repeat(64), + shared: true, + }), + personaEvent({ + createdAt: 1, + id: "a".repeat(64), + shared: false, + }), + ]); + + assert.deepEqual(publications, []); +}); + +test("an invalid canonical head does not resurrect an older shared persona", () => { + const invalidHead = { + ...personaEvent({ createdAt: 2, id: "a".repeat(64) }), + content: "{}", + }; + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "older-valid" }), + invalidHead, + ]); + + assert.deepEqual(publications, []); +}); + +test("only an exact shared true tag opts a persona into discovery", () => { + assert.equal( + personaEventIsShared(personaEvent({ createdAt: 1, id: "exact-shared" })), + true, + ); + for (const [index, sharedTag] of [ + ["shared"], + ["shared", "false"], + ["shared", "true", "extra"], + ].entries()) { + const event = personaEvent({ + createdAt: index + 2, + id: `malformed-${index}`, + shared: false, + sharedTag, + }); + assert.equal(personaEventIsShared(event), false); + assert.deepEqual(catalogPublicationsFromEvents([event]), []); + } + const duplicate = personaEvent({ + createdAt: 5, + id: "duplicate", + }); + duplicate.tags.push(["shared", "true"]); + assert.equal(personaEventIsShared(duplicate), false); +}); + +test("catalog avatars keep bounded http URLs and drop unsafe schemes", () => { + const safe = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "safe-avatar", + avatarUrl: "https://relay.example/avatar.png", + }), + ]), + [], + BOB, + ); + assert.equal(safe[0].avatarUrl, "https://relay.example/avatar.png"); + + const unsafe = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "unsafe-avatar", + avatarUrl: "javascript:alert(1)", + }), + ]), + [], + BOB, + ); + assert.equal(unsafe[0].avatarUrl, null); +}); + +/** The avatar a catalog entry projects for `avatarUrl`, or null if dropped. */ +function catalogAvatarUrl(avatarUrl) { + const personas = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "avatar-vector", avatarUrl }), + ]), + [], + BOB, + ); + return personas[0].avatarUrl; +} + +// An emoji avatar is self-contained, so it is the one `data:` avatar that can +// render on another member's machine. Dropping it left shared agents looking +// avatar-less in the catalog. +test("test_percent_encoded_emoji_svg_avatar_survives_the_catalog", () => { + const emojiAvatar = emojiAvatarDataUrl("🐝", "#FFCC00"); + + assert.equal(catalogAvatarUrl(emojiAvatar), emojiAvatar); +}); + +test("test_base64_svg_avatar_is_rejected", () => { + assert.equal( + catalogAvatarUrl(`data:image/svg+xml;base64,${btoa("")}`), + null, + ); +}); + +test("test_non_svg_data_avatar_is_rejected", () => { + assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null); +}); + +test("test_legacy_inline_raster_avatar_survives_the_catalog", () => { + for (const mime of ["png", "jpeg", "gif", "webp"]) { + const avatar = `data:image/${mime};base64,iVBORw0KGgo=`; + assert.equal(catalogAvatarUrl(avatar), avatar); + } +}); + +test("test_inline_raster_avatar_rejects_unbounded_or_malformed_payloads", () => { + const prefix = "data:image/png;base64,"; + const payloadLength = 256 * 1_024 - prefix.length; + const validPayloadLength = payloadLength - (payloadLength % 4); + const withinCap = `${prefix}${"a".repeat(validPayloadLength - 2)}==`; + assert.ok(withinCap.length <= 256 * 1_024); + assert.equal(catalogAvatarUrl(withinCap), withinCap); + assert.equal( + catalogAvatarUrl( + `${withinCap}${"a".repeat(256 * 1_024 - withinCap.length + 1)}`, + ), + null, + ); + assert.equal(catalogAvatarUrl("data:image/png;base64,not base64"), null); + assert.equal(catalogAvatarUrl("data:image/bmp;base64,aA=="), null); +}); + +test("test_oversized_inline_svg_avatar_is_rejected", () => { + const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`; + assert.equal(withinCap.length, 8_192); + assert.equal(catalogAvatarUrl(withinCap), withinCap); + assert.equal(catalogAvatarUrl(`${withinCap}a`), null); +}); + +// Catalog avatars render through `` (ProfileAvatar → AvatarImage), +// where an SVG document is never scripted, so a script-bearing avatar is +// accepted and inert rather than filtered — the projection must not silently +// start sanitizing markup it does not render. +test("test_script_bearing_inline_svg_avatar_is_accepted_and_rendered_inert", () => { + const scripted = `data:image/svg+xml,${encodeURIComponent( + '', + )}`; + + assert.equal(catalogAvatarUrl(scripted), scripted); +}); + +test("foreign allowlist behavior imports as owner-only", () => { + const personas = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "allowlist", + respondTo: "allowlist", + }), + ]), + [], + BOB, + ); + + assert.equal(personas[0].respondTo, "owner-only"); + assert.deepEqual(personas[0].respondToAllowlist, []); +}); + +test("a pending local share does not appear before relay confirmation", () => { + const localPersona = { + id: "local-reviewer", + displayName: "Local Reviewer", + avatarUrl: null, + systemPrompt: "Review local changes.", + runtime: null, + model: null, + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + shared: true, + sourceTeam: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + }; + + const personas = catalogPersonasFromPublications([], [localPersona], ALICE); + assert.deepEqual(personas, []); +}); + +function localPersona(overrides = {}) { + return { + id: "local-1", + displayName: "Relay Reviewer", + avatarUrl: null, + systemPrompt: "Review changes.", + runtime: null, + model: null, + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + shared: false, + sourceTeam: null, + catalogSource: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + ...overrides, + }; +} + +// The duplicate-add bug: a copy of Alice's entry carries a fresh local UUID, so +// matching by id finds nothing and the catalog offers "Add" again. Only the +// stored catalogSource coordinate links the copy back to the publication. +test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const copy = localPersona({ + id: "a-fresh-uuid", + catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, + }); + + const personas = catalogPersonasFromPublications(publications, [copy], BOB); + + assert.equal(personas.length, 1); + assert.equal( + personas[0].id, + "a-fresh-uuid", + "the projection must resolve to the existing local copy, not a synthetic id", + ); + assert.equal( + personas[0].isActive, + true, + "an added foreign entry must read as already selected", + ); +}); + +test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + // A same-named local persona with no provenance is a different agent. + const unrelated = localPersona({ id: "unrelated" }); + + const personas = catalogPersonasFromPublications( + publications, + [unrelated], + BOB, + ); + + assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].isActive, false); +}); + +// Provenance is per-owner: the same d-tag under a different publisher is a +// different agent, so a copy of Alice's must not mask Bob's entry. +test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "bob-reviewer", owner: BOB }), + ]); + const copyOfAlices = localPersona({ + id: "copy-of-alices", + catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, + }); + + const personas = catalogPersonasFromPublications( + publications, + [copyOfAlices], + ALICE, + ); + + assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].isActive, false); +}); + +test("test_own_publication_still_resolves_by_local_id", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const own = localPersona({ id: "reviewer", shared: true }); + + const personas = catalogPersonasFromPublications(publications, [own], ALICE); + + assert.equal(personas[0].id, "reviewer"); + assert.equal(personas[0].catalogSource.isOwn, true); +}); + +function pageOfEvents(count, startId, createdAt) { + return Array.from({ length: count }, (_, index) => + personaEvent({ + createdAt: typeof createdAt === "function" ? createdAt(index) : createdAt, + id: `event-${startId + index}`, + sourcePersonaId: `persona-${startId + index}`, + }), + ); +} + +function stubPagedRelay(pages) { + const filters = []; + mock.method(relayClient, "fetchEvents", (filter) => { + filters.push(filter); + return Promise.resolve(pages[filters.length - 1] ?? []); + }); + return filters; +} + +// A single limit-capped fetch drops every entry past the relay's clamp, making +// those agents undiscoverable. The walk must keep going while pages come back +// full, and must carry an `until` cursor derived from the oldest event seen. +test("test_full_page_is_followed_by_a_cursored_request_for_older_events", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([ + pageOfEvents(500, 0, (index) => 10_000 - index), + pageOfEvents(3, 500, 9_000), + ]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(filters.length, 2, "a full page must be followed by another"); + assert.equal(filters[0].until, undefined, "the first page has no cursor"); + assert.equal( + filters[1].until, + 10_000 - 499, + "the cursor must be the oldest created_at from the previous page", + ); + assert.equal( + publications.length, + 503, + "entries past the first page must still be discoverable", + ); +}); + +test("test_short_first_page_does_not_issue_a_second_request", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([pageOfEvents(2, 0, 10_000)]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(filters.length, 1); + assert.equal(publications.length, 2); +}); + +// `until` is inclusive on the relay, so consecutive pages overlap on the +// boundary timestamp. Without id dedupe the repeats would be counted twice. +test("test_overlapping_pages_are_deduped_by_event_id", async (t) => { + t.after(() => mock.restoreAll()); + const firstPage = pageOfEvents(500, 0, (index) => 10_000 - index); + const secondPage = [ + // The boundary event repeats because `until` includes its timestamp. + firstPage[firstPage.length - 1], + ...pageOfEvents(2, 500, 9_000), + ]; + stubPagedRelay([firstPage, secondPage]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(publications.length, 502, "the repeated event must count once"); +}); + +// The stop-on-no-progress guard: a full page whose events all share one +// created_at cannot advance the cursor, so paging must terminate instead of +// re-requesting the same page forever. +test("test_full_page_of_tied_timestamps_terminates_the_walk", async (t) => { + t.after(() => mock.restoreAll()); + const tiedPage = pageOfEvents(500, 0, 10_000); + const filters = stubPagedRelay([tiedPage, tiedPage, tiedPage, tiedPage]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal( + filters.length, + 2, + "the walk must stop once a page contributes nothing new", + ); + assert.equal(publications.length, 500); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts new file mode 100644 index 00000000000..02c3f8e2023 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -0,0 +1,382 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { + AgentPersona, + CatalogSourceCoordinate, + RelayEvent, + RespondToMode, +} from "@/shared/api/types"; +import { KIND_PERSONA } from "@/shared/constants/kinds"; + +export type CatalogPersonaShareLevel = "not-shared" | "none"; + +type CatalogAgentProjection = { + displayName: string; + avatarUrl: string | null; + systemPrompt: string; + runtime: string | null; + model: string | null; + provider: string | null; + namePool: string[]; + respondTo: RespondToMode | null; + parallelism: number | null; +}; + +export type PersonaCatalogPublication = { + eventId: string; + ownerPubkey: string; + sourcePersonaId: string; + createdAt: number; + agent: CatalogAgentProjection; +}; + +export type CatalogPersona = AgentPersona & { + catalogSource: CatalogSourceCoordinate & { + /** The publication event this projection was built from. */ + eventId: string; + /** Whether the current identity published it. */ + isOwn: boolean; + }; +}; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function extractTag(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag.length >= 2 && tag[0] === name && typeof tag[1] === "string", + ); + return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; +} + +export function personaEventIsShared(event: RelayEvent): boolean { + const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); + return ( + sharedTags.length === 1 && + sharedTags[0]?.length === 2 && + sharedTags[0]?.[1] === "true" + ); +} + +function isSafeHttpUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 2_048 || + /[\s()]/u.test(value) + ) { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +/** + * Emoji avatars are the one `data:` avatar a catalog entry keeps. + * + * They persist as inline, percent-encoded SVG (`emojiAvatarDataUrl` in + * `ProfileAvatarEditor.utils.ts`), so they are self-contained and render on + * any member's machine — unlike a bundled runtime-default avatar, whose local + * asset path means nothing to another install. The accepted shape is exactly + * that prefix: the trailing comma is what rejects `;base64` payloads, and + * every other `data:` MIME stays rejected. Catalog avatars render through + * `` (`ProfileAvatar` → `AvatarImage`), where SVG script never + * executes, so bounding the length is the remaining concern — 8 KiB is an + * order of magnitude above the ~700 characters an emoji avatar encodes to. + */ +const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; +const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; + +/** + * Shared persona heads can carry an uploaded avatar as an inline raster. Keep + * those self-contained images renderable without accepting arbitrary `data:` + * URLs: only the raster MIME types browsers decode in ``, strict base64 + * shape, and a bound no larger than the relay's event-content ceiling. + */ +const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; +const INLINE_RASTER_AVATAR_RE = + /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; + +function isInlineSvgAvatar(value: unknown): value is string { + return ( + typeof value === "string" && + value.startsWith(INLINE_SVG_AVATAR_PREFIX) && + value.length <= MAX_INLINE_SVG_AVATAR_LENGTH + ); +} + +function isInlineRasterAvatar(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length > MAX_INLINE_RASTER_AVATAR_LENGTH + ) { + return false; + } + const match = INLINE_RASTER_AVATAR_RE.exec(value); + return match !== null && (match[1]?.length ?? 0) % 4 === 0; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { + let parsed: unknown; + try { + parsed = JSON.parse(event.content); + } catch { + return null; + } + if ( + !isObject(parsed) || + typeof parsed.display_name !== "string" || + parsed.display_name.trim().length === 0 + ) { + return null; + } + + const avatarUrl = + isSafeHttpUrl(parsed.avatar_url) || + isInlineSvgAvatar(parsed.avatar_url) || + isInlineRasterAvatar(parsed.avatar_url) + ? parsed.avatar_url + : null; + const namePool = Array.isArray(parsed.name_pool) + ? parsed.name_pool.filter( + (candidate): candidate is string => typeof candidate === "string", + ) + : []; + const respondTo = + parsed.respond_to === "allowlist" + ? "owner-only" + : parsed.respond_to === "owner-only" || parsed.respond_to === "anyone" + ? parsed.respond_to + : null; + const parallelism = + typeof parsed.parallelism === "number" && + Number.isInteger(parsed.parallelism) && + parsed.parallelism >= 1 && + parsed.parallelism <= 32 + ? parsed.parallelism + : null; + + return { + displayName: parsed.display_name, + avatarUrl, + systemPrompt: + typeof parsed.system_prompt === "string" ? parsed.system_prompt : "", + runtime: optionalString(parsed.runtime), + model: optionalString(parsed.model), + provider: optionalString(parsed.provider), + namePool, + respondTo, + parallelism, + }; +} + +/** + * Collapse relay results to the canonical NIP-33 head for each persona + * coordinate, then keep only exact `["shared", "true"]` heads. + * + * The relay normally returns one replaceable head. The client-side collapse is + * defense in depth for older relays and fixtures, and deliberately claims the + * coordinate before parsing so an invalid or unshared newest head cannot + * resurrect an older shared definition. + */ +export function catalogPublicationsFromEvents( + events: readonly RelayEvent[], +): PersonaCatalogPublication[] { + const sorted = [...events].sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ); + const seenCoordinates = new Set(); + const publications: PersonaCatalogPublication[] = []; + + for (const event of sorted) { + if (event.kind !== KIND_PERSONA) continue; + const sourcePersonaId = extractTag(event, "d"); + if (!sourcePersonaId) continue; + const ownerPubkey = event.pubkey.toLowerCase(); + const coordinate = `${ownerPubkey}:${sourcePersonaId}`; + if (seenCoordinates.has(coordinate)) continue; + seenCoordinates.add(coordinate); + + if (!personaEventIsShared(event)) continue; + const agent = parsePersonaContent(event); + if (!agent) continue; + publications.push({ + eventId: event.id, + ownerPubkey, + sourcePersonaId, + createdAt: event.created_at, + agent, + }); + } + + return publications; +} + +/** + * Events per catalog page. + * + * Kept well under the relay's 1,000-row `query_events` clamp so a page that + * comes back full is a reliable "there may be more" signal rather than a + * silently truncated result. + */ +const CATALOG_PAGE_SIZE = 500; + +/** + * Hard bound on pages walked, so a relay that keeps returning full pages can + * never spin this forever. + */ +const MAX_CATALOG_PAGES = 40; + +/** + * Read every shared persona event, page by page. + * + * A single `limit`-capped fetch silently truncates once a community publishes + * more agents than the relay's clamp, and the entries that fall off are simply + * undiscoverable. Paging walks backwards through `created_at` using the only + * cursor a WS `REQ` filter carries — `until` — which the relay treats as + * *inclusive*, so consecutive pages overlap on tied timestamps. Two things + * follow, and both are load-bearing: + * + * - dedupe by event id, because the boundary events repeat; and + * - stop when a page contributes nothing new, because a page whose events all + * share one `created_at` would otherwise be requested forever. + */ +export async function fetchPersonaCatalogPublications(): Promise< + PersonaCatalogPublication[] +> { + const byId = new Map(); + let until: number | undefined; + + for (let page = 0; page < MAX_CATALOG_PAGES; page += 1) { + const events = await relayClient.fetchEvents({ + kinds: [KIND_PERSONA], + limit: CATALOG_PAGE_SIZE, + ...(until === undefined ? {} : { until }), + }); + + const sizeBefore = byId.size; + let oldestCreatedAt = Number.POSITIVE_INFINITY; + for (const event of events) { + byId.set(event.id, event); + oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); + } + + // A short page is the end of the catalog; a page of only-repeats means the + // cursor cannot advance past a run of tied timestamps. + if (events.length < CATALOG_PAGE_SIZE || byId.size === sizeBefore) { + break; + } + until = oldestCreatedAt; + } + + return catalogPublicationsFromEvents([...byId.values()]); +} + +function publicationToPersona( + publication: PersonaCatalogPublication, + localPersona: AgentPersona | undefined, + isOwn: boolean, +): CatalogPersona { + const timestamp = new Date(publication.createdAt * 1_000).toISOString(); + const basePersona: AgentPersona = localPersona ?? { + id: `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + displayName: publication.agent.displayName, + avatarUrl: publication.agent.avatarUrl, + systemPrompt: publication.agent.systemPrompt, + runtime: publication.agent.runtime, + model: publication.agent.model, + provider: publication.agent.provider, + namePool: publication.agent.namePool, + isBuiltIn: false, + isActive: false, + shared: true, + sourceTeam: null, + envVars: {}, + respondTo: publication.agent.respondTo, + respondToAllowlist: [], + parallelism: publication.agent.parallelism, + createdAt: timestamp, + updatedAt: timestamp, + }; + + return { + ...basePersona, + // Catalog membership is relay-confirmed by the shared event itself. Do not + // let a local pending toggle override this projection. + shared: true, + catalogSource: { + eventId: publication.eventId, + ownerPubkey: publication.ownerPubkey, + isOwn, + personaId: publication.sourcePersonaId, + }, + }; +} + +export function catalogPersonasFromPublications( + publications: readonly PersonaCatalogPublication[], + localPersonas: readonly AgentPersona[], + currentPubkey: string | null | undefined, +): CatalogPersona[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + const personas: CatalogPersona[] = []; + + for (const publication of publications) { + const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; + personas.push( + publicationToPersona( + publication, + findLocalPersonaForCatalogEntry(localPersonas, { + ownerPubkey: publication.ownerPubkey, + personaId: publication.sourcePersonaId, + isOwn, + }), + isOwn, + ), + ); + } + + return personas.sort((left, right) => + left.displayName.localeCompare(right.displayName), + ); +} + +/** + * The local persona backing a catalog entry, if the user already has it. + * + * An own publication is found by id — its `d`-tag *is* the local persona id. A + * copy of another owner's entry carries a fresh local id instead, so the only + * link back is the `catalogSource` coordinate stored on the copy. Matching on + * that coordinate is what stops the catalog from offering "Add" for an entry + * the user already added, which would mint a second copy. + */ +export function findLocalPersonaForCatalogEntry( + localPersonas: readonly AgentPersona[], + source: CatalogSourceCoordinate & { isOwn: boolean }, +): AgentPersona | undefined { + if (source.isOwn) { + return localPersonas.find((persona) => persona.id === source.personaId); + } + return localPersonas.find( + (persona) => + persona.catalogSource?.ownerPubkey === source.ownerPubkey && + persona.catalogSource?.personaId === source.personaId, + ); +} + +export function isCatalogPersona( + persona: AgentPersona, +): persona is CatalogPersona { + return "catalogSource" in persona && isObject(persona.catalogSource); +} diff --git a/desktop/src/features/agents/lib/personaEditCaches.ts b/desktop/src/features/agents/lib/personaEditCaches.ts new file mode 100644 index 00000000000..c5071d1246b --- /dev/null +++ b/desktop/src/features/agents/lib/personaEditCaches.ts @@ -0,0 +1,42 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { evictUsersBatchEntries } from "@/features/profile/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Refresh every cache a saved persona edit can invalidate. + * + * Shared by the plain edit mutation and the publish-on-save edit mutation so + * the two cannot drift on what a saved edit refreshes. + */ +export async function invalidatePersonaEditCaches( + queryClient: QueryClient, + personaId: string, +): Promise { + // Evict per-pubkey users-batch-entry caches for agents linked to this + // persona so the batch invalidation below refetches fresh profiles instead + // of re-reading stale entries (mirrors useUpdateManagedAgentMutation). + const agents = queryClient.getQueryData(["managed-agents"]); + if (agents) { + evictUsersBatchEntries( + queryClient, + agents + .filter((agent) => agent.personaId === personaId) + .map((agent) => agent.pubkey.toLowerCase()), + ); + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["personas"] }), + queryClient.invalidateQueries({ queryKey: ["managed-agents"] }), + // Persona avatar changes re-sync linked agents' relay profiles; + // invalidate cached user-profile and users-batch queries so the UI picks + // up the updated kind:0 picture without waiting for staleTime expiry — + // covers agent cards, message timelines, and member lists. + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "user-profile" || + query.queryKey[0] === "users-batch", + }), + ]); +} diff --git a/desktop/src/features/agents/lib/personaSaveNotice.test.mjs b/desktop/src/features/agents/lib/personaSaveNotice.test.mjs new file mode 100644 index 00000000000..36f3fcdc8a0 --- /dev/null +++ b/desktop/src/features/agents/lib/personaSaveNotice.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { personaSaveNotice } from "./personaSaveNotice.ts"; + +test("test_plain_save_notice_says_nothing_about_the_catalog", () => { + const notice = personaSaveNotice("Helper", null); + assert.equal(notice, "Updated Helper."); + assert.ok(!/catalog/i.test(notice)); +}); + +test("test_accepted_publish_notice_claims_the_catalog_has_the_edit", () => { + assert.match( + personaSaveNotice("Helper", "published"), + /published it to the community catalog/, + ); +}); + +// The whole point of routing "Save and publish" through the strict command is +// that a queued edit must NOT be reported as published — the relay hasn't taken +// it yet, so the catalog still shows the old definition. +test("test_queued_publish_notice_does_not_claim_the_edit_is_published", () => { + const notice = personaSaveNotice("Helper", "queued"); + assert.match(notice, /queued/); + assert.ok( + !/\bpublished\b/.test(notice), + "a queued edit must not be described as published", + ); +}); diff --git a/desktop/src/features/agents/lib/personaSaveNotice.ts b/desktop/src/features/agents/lib/personaSaveNotice.ts new file mode 100644 index 00000000000..f75f0e1c68a --- /dev/null +++ b/desktop/src/features/agents/lib/personaSaveNotice.ts @@ -0,0 +1,24 @@ +import type { PersonaSharePublicationResult } from "@/shared/api/tauriPersonas"; + +/** + * The confirmation shown after a persona edit is saved. + * + * `publicationStatus` is null when the edit did not promise publication, so + * the copy stays silent about the catalog. When it did, the copy must + * distinguish a relay-accepted publish from a queued one — a "published" + * message for an edit still sitting in the outbox is the promise the + * "Save and publish" button was making falsely. + */ +export function personaSaveNotice( + displayName: string, + publicationStatus: PersonaSharePublicationResult["publicationStatus"] | null, +): string { + switch (publicationStatus) { + case "published": + return `Updated ${displayName} and published it to the community catalog.`; + case "queued": + return `Updated ${displayName}. Publishing to the community catalog is queued and will appear after the relay accepts the update.`; + default: + return `Updated ${displayName}.`; + } +} diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts new file mode 100644 index 00000000000..c7835f93739 --- /dev/null +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -0,0 +1,115 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchPersonaCatalogPublications, + type PersonaCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { invalidatePersonaEditCaches } from "@/features/agents/lib/personaEditCaches"; +import { relayClient } from "@/shared/api/relayClient"; +import { + setPersonaShared, + updatePersonaAndPublish, +} from "@/shared/api/tauriPersonas"; +import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types"; +import { KIND_PERSONA } from "@/shared/constants/kinds"; + +export function personaCatalogQueryKey(communityId: string | null) { + return ["persona-catalog", communityId] as const; +} + +export function usePersonaCatalogQuery(communityId: string | null) { + return useQuery({ + enabled: communityId !== null, + queryKey: personaCatalogQueryKey(communityId), + queryFn: fetchPersonaCatalogPublications, + staleTime: 30_000, + refetchInterval: 120_000, + }); +} + +export function usePersonaCatalogLiveUpdates(communityId: string | null): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!communityId) return; + let disposed = false; + let dispose: (() => Promise) | null = null; + + void relayClient + .subscribeLive({ kinds: [KIND_PERSONA], limit: 0 }, () => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Couldn’t subscribe to the community agent catalog", + error, + ); + }); + + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }); + + return () => { + disposed = true; + unsubscribeReconnect(); + if (dispose) void dispose(); + }; + }, [communityId, queryClient]); +} + +export function useSetPersonaCatalogSharedMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, shared }: { id: string; shared: boolean }) => + setPersonaShared(id, shared), + onSuccess: (result) => { + queryClient.setQueryData( + ["personas"], + (current) => + current?.map((persona) => + persona.id === result.persona.id ? result.persona : persona, + ) ?? [result.persona], + ); + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }, + }); +} + +/** + * Save a persona edit and publish its catalog head, reporting the relay's + * verdict. + * + * The plain edit mutation only enqueues the head best-effort, so it cannot back + * the "Save and publish" promise. This awaits the relay and additionally + * refreshes the catalog query, since the published edit changes what the + * catalog shows. + */ +export function useUpdatePersonaAndPublishMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: UpdatePersonaInput) => updatePersonaAndPublish(input), + onSettled: async (_data, _error, variables) => { + await Promise.all([ + invalidatePersonaEditCaches(queryClient, variables.id), + queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }), + ]); + }, + }); +} diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index a1cbbf93fe4..0dc12ddfd1e 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -35,7 +35,7 @@ test("startPersonaSync backfills history including the deletion kind", () => { return Promise.resolve(() => Promise.resolve()); }); - startPersonaSync("owner-pubkey", () => false); + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); assert.equal(fetchCalls.length, 1, "must do exactly one backfill fetch"); assert.deepEqual( @@ -58,3 +58,53 @@ test("startPersonaSync backfills history including the deletion kind", () => { mock.reset(); }); + +// Regression guard for the arrival-scope fix (F6): the reconcile must carry the +// relay this subscription was opened on, NOT whichever community happens to be +// active when the reconcile runs. Without the forwarded URL the backend falls +// back to the active workspace and an in-flight event lands in the wrong +// community's scoped retention store on a mid-flight switch. +test("startPersonaSync forwards its own relay as the event arrival relay", async () => { + const invokes = []; + // @tauri-apps/api/core reads `window.__TAURI_INTERNALS__.invoke`. + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (cmd, args) => { + invokes.push({ cmd, args }); + return Promise.resolve(); + }, + }, + }; + + const ownEvent = { id: "e1", pubkey: "owner-pubkey", kind: KIND_PERSONA }; + const foreignEvent = { id: "e2", pubkey: "someone-else", kind: KIND_PERSONA }; + + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ownEvent, foreignEvent]), + ); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + + startPersonaSync("owner-pubkey", "wss://community-a.example", () => false); + // Let the backfill promise chain and the reconcile invoke settle. + await new Promise((resolve) => setImmediate(resolve)); + + const reconciles = invokes.filter( + (call) => call.cmd === "reconcile_inbound_persona_event", + ); + assert.equal( + reconciles.length, + 1, + "only the subscribed author's event reconciles", + ); + assert.equal( + reconciles[0].args.arrivalRelayUrl, + "wss://community-a.example", + "reconcile must carry the subscription's relay as the arrival relay", + ); + assert.equal(JSON.parse(reconciles[0].args.eventJson).id, "e1"); + + mock.reset(); + delete globalThis.window; +}); diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index e713ed71d15..f18194c5c6e 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -20,19 +20,28 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; -// Start the persona/team/agent/deletion sync for `pubkey`: one-shot backfill -// of existing heads + tombstones, then a live subscription. Returns a disposer -// that closes the live subscription. Extracted from the hook so the wiring is -// unit-testable without a React renderer (see `usePersonaSync.test.mjs`). +// Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: +// one-shot backfill of existing heads + tombstones, then a live subscription. +// Returns a disposer that closes the live subscription. Extracted from the hook +// so the wiring is unit-testable without a React renderer (see +// `usePersonaSync.test.mjs`). +// +// `relayUrl` is the community this subscription is bound to, and every reconcile +// carries it as the event's arrival relay. Capturing it here — rather than +// letting the backend read whichever workspace is active when the reconcile runs +// — is what keeps an in-flight event out of the next community's scoped store. export function startPersonaSync( pubkey: string, + relayUrl: string, onCancelled: () => boolean, ): () => Promise { const reconcile = (event: RelayEvent) => { if (event.pubkey !== pubkey) return; - void reconcileInboundPersonaEvent(JSON.stringify(event)).catch((error) => { - console.warn("[usePersonaSync] reconcile failed:", error); - }); + void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch( + (error) => { + console.warn("[usePersonaSync] reconcile failed:", error); + }, + ); }; // One-shot backfill of existing heads + tombstones (closes the fresh-start @@ -68,23 +77,27 @@ export function startPersonaSync( // Subscribes to this device's own persona/team/agent projection + deletion // events and patches each into the local store. The subscription is keyed on -// the active pubkey: an identity switch re-runs the effect, whose cleanup -// closes the old subscription before a new one opens on the new pubkey's -// filter — so no stale-coordinate subscription survives. +// the active pubkey and relay: an identity or community switch re-runs the +// effect, whose cleanup closes the old subscription before a new one opens on +// the new filter — so no stale-coordinate subscription survives, and every +// reconcile is attributed to the community it was subscribed to. // // A fresh device that comes online AFTER another already published gets no // history from a live-only subscription: relayClient's replayLiveSubscriptions // only replays from a since-cursor that is undefined until the first live // event arrives. So `startPersonaSync` does an explicit one-shot history fetch // up front and feeds each event through the same reconcile path. -export function usePersonaSync(pubkey: string | undefined): void { +export function usePersonaSync( + pubkey: string | undefined, + relayUrl: string | undefined, +): void { React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - const dispose = startPersonaSync(pubkey, () => cancelled); + const dispose = startPersonaSync(pubkey, relayUrl, () => cancelled); return () => { cancelled = true; void dispose(); }; - }, [pubkey]); + }, [pubkey, relayUrl]); } diff --git a/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx b/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx new file mode 100644 index 00000000000..0c84e99275a --- /dev/null +++ b/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx @@ -0,0 +1,47 @@ +import { CustomHarnessForm } from "@/features/settings/ui/CustomHarnessForm"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; + +/** + * Registers a custom ACP harness from inside an agent dialog, so "New agent" + * is a complete entry point and not a dead end that sends the user to + * Settings. Hosts the same `CustomHarnessForm` the harness catalog uses. + */ +export function AddCustomHarnessDialog({ + onOpenChange, + onSaved, + open, +}: { + onOpenChange: (open: boolean) => void; + /** Called with the id of the harness that was just registered. */ + onSaved: (id: string) => void; + open: boolean; +}) { + return ( + + + + Register any ACP-speaking agent tool as a selectable harness. +

+ } + onCancel={() => onOpenChange(false)} + onSaved={(id) => { + // Dismiss on save as well as cancel — both exits belong to this + // dialog, so callers only handle the resulting selection. + onOpenChange(false); + onSaved(id); + }} + /> +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 1bdd93673bf..e7a68211dcd 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -133,6 +133,32 @@ export function AgentCreationPreview({ isAvatarMenuOpen && activeTab === "emoji", ); + // 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(() => { + if (!isAvatarMenuOpen || activeTab !== "emoji") { + return; + } + + let animationFrame = 0; + const focusSearchInput = () => { + const searchInput = + emojiPickerContainerRef.current + ?.querySelector("em-emoji-picker") + ?.shadowRoot?.querySelector( + 'input[type="search"]', + ) ?? null; + if (!searchInput) { + animationFrame = window.requestAnimationFrame(focusSearchInput); + return; + } + searchInput.focus(); + }; + + animationFrame = window.requestAnimationFrame(focusSearchInput); + return () => window.cancelAnimationFrame(animationFrame); + }, [activeTab, isAvatarMenuOpen]); + const customColorDraft = React.useMemo( () => hsvToHex(customHue, customSaturation, customValue), [customHue, customSaturation, customValue], @@ -550,6 +576,7 @@ export function AgentCreationPreview({ style={emojiMartThemeVars} > void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, + options: AgentDefinitionSubmitOptions, ) => Promise; + /** Publishes saved changes when the edited agent is shared in the catalog. */ + publishCatalogUpdatesOnSave?: boolean; /** Rendered below the form fields in create mode only ("Where to run"). */ createRunSection?: React.ReactNode; /** Extra create-mode submit gate (e.g. incomplete provider config). */ createSubmitBlocked?: boolean; }; +export type AgentDefinitionSubmitOptions = { + publishCatalogUpdates: boolean; +}; + const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, ease: [0.23, 1, 0.32, 1], @@ -121,6 +134,7 @@ export function AgentDefinitionDialog({ runtimesLoading = false, onOpenChange, onSubmit, + publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { @@ -158,6 +172,8 @@ export function AgentDefinitionDialog({ const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); + const [hasUserChanges, setHasUserChanges] = React.useState(false); + const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const { globalConfig, inheritedDefaults: { @@ -212,6 +228,7 @@ export function AgentDefinitionDialog({ // Advanced always starts collapsed and only changes from its toggle. setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setHasUserChanges(false); isRuntimeAutoSeededRef.current = false; hasSeededForOpenRef.current = false; }, [initialValues, open]); @@ -297,6 +314,8 @@ export function AgentDefinitionDialog({ behaviorSeedRef.current = emptyPersonaBehaviorDraft; setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setHasUserChanges(false); + setIsAddHarnessOpen(false); // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the // [initialValues, open] effect resets both when the dialog re-opens. } @@ -348,14 +367,19 @@ export function AgentDefinitionDialog({ }; if ("id" in initialValues) { - await onSubmit({ - id: initialValues.id, - ...baseInput, - }); + await onSubmit( + { + id: initialValues.id, + ...baseInput, + }, + { + publishCatalogUpdates: publishCatalogUpdatesOnSave && hasUserChanges, + }, + ); return; } - await onSubmit(baseInput); + await onSubmit(baseInput, { publishCatalogUpdates: false }); } function handleSubmitForm(event: React.FormEvent) { @@ -382,6 +406,7 @@ export function AgentDefinitionDialog({ enabled: open, }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { + setHasUserChanges(true); setAiConfigurationMode(nextMode); setIsCustomProviderEditing(false); setIsCustomModelEditing(false); @@ -553,44 +578,15 @@ export function AgentDefinitionDialog({ const showCustomProviderInput = llmProviderFieldVisible && isCustomProviderEditing; const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE; - const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimes), - [runtimes], - ); - const blankRuntimeOptionLabel = runtimesLoading - ? "Loading harnesses..." - : isCreateMode - ? "Choose a harness" - : "No preference (use app default)"; - const runtimeDropdownOptions: PersonaDropdownOption[] = [ - ...(!isCreateMode - ? [ - { - label: blankRuntimeOptionLabel, - value: NO_RUNTIME_DROPDOWN_VALUE, - }, - ] - : []), - ...sortedRuntimes.map((candidate) => ({ - disabled: - isCreateMode && - defaultRuntime !== null && - candidate.availability !== "available", - label: `${formatRuntimeOptionLabel(candidate)}${ - isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : "" - }`, - value: candidate.id, - })), - ]; - if ( - runtime.trim().length > 0 && - !runtimeDropdownOptions.some((option) => option.value === runtime) - ) { - runtimeDropdownOptions.push({ - label: `${runtime.trim()} (current)`, - value: runtime.trim(), + const { blankRuntimeOptionLabel, runtimeDropdownOptions } = + buildPersonaRuntimeDropdownOptions({ + defaultRuntimeId: defaultRuntime?.id, + isCreateMode, + runtime, + runtimes, + runtimesLoading, }); - } + runtimeDropdownOptions.push(ADD_CUSTOM_HARNESS_OPTION); const runtimeSummaryLabel = selectedRuntime ? formatRuntimeOptionLabel(selectedRuntime) : runtime.trim() || "Not configured"; @@ -675,8 +671,13 @@ export function AgentDefinitionDialog({ } function handleRuntimeDropdownChange(nextValue: string) { - const nextRuntime = - nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue; + const action = runtimeDropdownAction(nextValue); + if (action.kind === "add-custom-harness") { + setIsAddHarnessOpen(true); + return; + } + setHasUserChanges(true); + const nextRuntime = action.runtimeId; // The user made an explicit choice — no longer auto-seeded. isRuntimeAutoSeededRef.current = false; setRuntime(nextRuntime); @@ -692,7 +693,17 @@ export function AgentDefinitionDialog({ ); } + // Routed through the normal change handler so a harness registered inline + // resets model/provider exactly as a hand-picked one would. Scoped to `open` + // so a pending id can't outlive the dialog that started the registration. + const selectSavedHarness = usePendingHarnessSelection( + runtimes, + handleRuntimeDropdownChange, + open, + ); + function handleProviderDropdownChange(nextValue: string) { + setHasUserChanges(true); const nextProvider = nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") { @@ -710,6 +721,7 @@ export function AgentDefinitionDialog({ } function handleModelDropdownChange(nextValue: string) { + setHasUserChanges(true); applySelection( selectionOnModelDropdownChange(selection, { nextValue, @@ -736,42 +748,38 @@ export function AgentDefinitionDialog({ headerClassName="pb-2" title={title} footer={ -
- - -
+ handleOpenChange(false)} + publishesCatalogUpdates={ + publishCatalogUpdatesOnSave && hasUserChanges + } + submitBlockReason={null} + submitLabel={submitLabel} + /> } >
setHasUserChanges(true)} onSubmit={handleSubmitForm} > setAvatarUrl("")} + onClearAvatar={() => { + setHasUserChanges(true); + setAvatarUrl(""); + }} onUploadPendingChange={setIsAvatarUploadPending} - onSelectAvatar={setAvatarUrl} + onSelectAvatar={(nextAvatarUrl) => { + setHasUserChanges(true); + setAvatarUrl(nextAvatarUrl); + }} />
@@ -958,6 +966,12 @@ export function AgentDefinitionDialog({ returnFocusRef={aiDefaultsTriggerRef} /> + + {isCreateMode ? createRunSection : null}
@@ -1008,7 +1022,10 @@ export function AgentDefinitionDialog({ model={model} modelTuningRuntimeId={runtime} namePoolText={namePoolText} - onBehaviorDraftChange={setBehaviorDraft} + onBehaviorDraftChange={(nextBehaviorDraft) => { + setHasUserChanges(true); + setBehaviorDraft(nextBehaviorDraft); + }} onEnvVarsChange={setEnvVars} onNamePoolTextChange={setNamePoolText} provider={effectiveProvider} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx new file mode 100644 index 00000000000..92428ad95cb --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/shared/ui/button"; + +type AgentDefinitionDialogFooterProps = { + canSubmit: boolean; + isAvatarUploadPending: boolean; + isPending: boolean; + onCancel: () => void; + publishesCatalogUpdates: boolean; + submitBlockReason: string | null; + submitLabel: string; +}; + +export function AgentDefinitionDialogFooter({ + canSubmit, + isAvatarUploadPending, + isPending, + onCancel, + publishesCatalogUpdates, + submitBlockReason, + submitLabel, +}: AgentDefinitionDialogFooterProps) { + return ( +
+
+ {submitBlockReason ? ( +

+ {submitBlockReason} +

+ ) : null} + {publishesCatalogUpdates ? ( +

+ This agent is in the community catalog. Your changes will be + published when you save. +

+ ) : null} +
+ +
+ + +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx new file mode 100644 index 00000000000..50109143cdc --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/shared/lib/cn"; + +export function AgentDefinitionMetadata({ + className, + isBuiltIn, + model, + runtime, +}: { + className?: string; + isBuiltIn: boolean; + model: string | null; + runtime: string | null; +}) { + const items = [ + { + label: "Type", + value: isBuiltIn ? "Built-in agent" : "Custom agent", + }, + { + label: "Preferred model", + value: model ?? "Use app default", + }, + { + label: "Preferred runtime", + value: runtime ?? "Use app default", + }, + ]; + + return ( +
+
+ {items.map((item, index) => ( +
0 && + "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", + )} + key={item.label} + > +

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index 02a6d0e64aa..f5be3cc7e87 100644 --- a/desktop/src/features/agents/ui/AgentDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDialog.tsx @@ -11,7 +11,10 @@ import type { AgentCreateIntent } from "./agentCreateIntent"; import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog"; import { createPersonaDialogState } from "./personaDialogState"; -import { AgentDefinitionDialog } from "./AgentDefinitionDialog"; +import { + AgentDefinitionDialog, + type AgentDefinitionSubmitOptions, +} from "./AgentDefinitionDialog"; import { WhereToRunSection } from "./WhereToRunSection"; import { canSubmitWhereToRun, @@ -64,7 +67,9 @@ type AgentDialogDefinitionEditProps = { onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, + options: AgentDefinitionSubmitOptions, ) => Promise; + publishCatalogUpdatesOnSave?: boolean; }; type AgentDialogProps = diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 601d57f95d5..f3c410e2ff2 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -83,6 +83,12 @@ import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; +import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog"; +import { + ADD_CUSTOM_HARNESS_OPTION, + runtimeDropdownAction, + usePendingHarnessSelection, +} from "./addCustomHarness"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, @@ -157,6 +163,7 @@ export function AgentInstanceEditDialog({ const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? ""); const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); + const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const shouldReduceMotion = useReducedMotion(); // Runtime selector: defaults to "custom" until the dialog opens and the @@ -191,6 +198,7 @@ export function AgentInstanceEditDialog({ setAvatarUrl(agent.avatarUrl ?? ""); setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setIsAddHarnessOpen(false); runtimeTouched.current = false; const matched = runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ?? @@ -244,6 +252,7 @@ export function AgentInstanceEditDialog({ value: selectedRuntimeId, }); } + options.push(ADD_CUSTOM_HARNESS_OPTION); return options; }, [sortedRuntimes, selectedRuntimeId]); @@ -484,8 +493,12 @@ export function AgentInstanceEditDialog({ } function handleRuntimeDropdownChange(nextValue: string) { - const nextRuntimeId = - nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue; + const action = runtimeDropdownAction(nextValue); + if (action.kind === "add-custom-harness") { + setIsAddHarnessOpen(true); + return; + } + const nextRuntimeId = action.runtimeId; const previousRuntimeId = selectedRuntimeId; const nextRuntime = runtimes.find((r) => r.id === nextRuntimeId); @@ -532,6 +545,16 @@ export function AgentInstanceEditDialog({ ); } + // Routed through the normal change handler so a harness registered inline + // pins its command and resets model/provider like a hand-picked one. Scoped + // to `open` so a pending id can't outlive the dialog that started the + // registration. + const selectSavedHarness = usePendingHarnessSelection( + runtimes, + handleRuntimeDropdownChange, + open, + ); + function handleProviderDropdownChange(nextValue: string) { const nextProvider = nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; @@ -949,6 +972,11 @@ export function AgentInstanceEditDialog({

) : null} +
{selectedRuntimeId === "custom" && !inheritHarness ? (
diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index ad1219310d7..4a9584dfb9a 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -15,6 +15,8 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; + // ── Types ───────────────────────────────────────────────────────────────────── type ImportPhase = "preview" | "confirming" | "result"; @@ -164,6 +166,12 @@ function PreviewBody({ ) : null}
+ +

A new agent will be created with a fresh keypair. The imported agent is independent of the source — identity never travels. diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 8e1c47c6157..f24a3c06d7f 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { OctagonX } from "lucide-react"; +import { OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -20,7 +20,10 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { + AGENT_CARD_GRID_COLUMNS_CLASS, + UnifiedAgentsSection, +} from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -70,11 +73,14 @@ export function AgentsView() { const runningAgentCount = agents.managedAgents.filter((agent) => isManagedAgentActive(agent), ).length; - // Show the resolved effective model, not just the structured `model` field: - // most providers persist the model as a provider env var (e.g. DATABRICKS_MODEL) - // or inherit a baked build default, leaving `globalConfig.model` null. - const configuredGlobalModel = inheritedDefaults.model.value; - + const hasSavedAgentDefaults = Boolean( + globalConfig.preferred_runtime?.trim() || + globalConfig.provider?.trim() || + globalConfig.model?.trim() || + Object.values(globalConfig.env_vars).some( + (value) => value.trim().length > 0, + ), + ); // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { // Consume a snapshot import that was enqueued before navigation (e.g. from @@ -106,18 +112,23 @@ export function AgentsView() { return ( <>

-
+
{runningAgentCount > 0 ? ( @@ -135,11 +146,10 @@ export function AgentsView() { ) : null}
} - className="mx-auto w-full max-w-[996px]" description="Set up and manage your agents." title="Agents" /> -
+
0} personas={personas.libraryPersonas} personasError={ personas.personasQuery.error instanceof Error @@ -186,10 +195,8 @@ export function AgentsView() { } isPersonasLoading={personas.personasQuery.isLoading} isPersonasPending={personas.isPending} - onCreatePersona={() => { - openUnifiedCreate(); - }} - onChooseCatalog={personas.openCatalog} + onCreatePersona={openUnifiedCreate} + onDiscoverPersonas={personas.openCatalog} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} @@ -289,9 +296,11 @@ export function AgentsView() { error={ personas.updatePersonaMutation.error instanceof Error ? personas.updatePersonaMutation.error - : personas.createPersonaMutation.error instanceof Error - ? personas.createPersonaMutation.error - : null + : personas.updatePersonaAndPublishMutation.error instanceof Error + ? personas.updatePersonaAndPublishMutation.error + : personas.createPersonaMutation.error instanceof Error + ? personas.createPersonaMutation.error + : null } initialValues={personas.personaDialogState.initialValues} isPending={personas.isPending} @@ -303,8 +312,22 @@ export function AgentsView() { personas.setPersonaDialogState(null); } }} - onSubmit={personas.handleSubmit} + onSubmit={(input, options) => + personas.handleSubmit( + input, + undefined, + undefined, + undefined, + options, + ) + } open={personas.personaDialogState !== null} + publishCatalogUpdatesOnSave={ + "id" in personas.personaDialogState.initialValues && + personas.sharedCatalogPersonaIdSet.has( + personas.personaDialogState.initialValues.id, + ) + } submitLabel={personas.personaDialogState.submitLabel} title={personas.personaDialogState.title} /> @@ -330,8 +353,20 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + void personas.setPersonaCatalogShareLevel( + shareTarget.persona, + shareLevel, + ); + }} onExport={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; @@ -358,6 +393,7 @@ export function AgentsView() { personas.handleExportSnapshot( personas.personaToExportSnapshot.persona, personas.personaToExportSnapshot.linkedAgentPubkey, + personas.personaToExportSnapshot.effectiveAvatarUrl, memoryLevel, format, ); @@ -390,8 +426,8 @@ export function AgentsView() { {personas.isCatalogDialogOpen ? ( { personas.clearFeedback("catalog"); }} diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx index 70d063098b7..4fdd6db26f3 100644 --- a/desktop/src/features/agents/ui/CreateIdentityCard.tsx +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -6,7 +6,7 @@ import { cn } from "@/shared/lib/cn"; type CreateIdentityCardProps = React.ButtonHTMLAttributes & { ariaLabel: string; dataTestId: string; - label: string; + label?: string; }; export const CreateIdentityCard = React.forwardRef< @@ -30,7 +30,9 @@ export const CreateIdentityCard = React.forwardRef< > - {label} + {label ? ( + {label} + ) : null} ); diff --git a/desktop/src/features/agents/ui/PersonaAddedBy.tsx b/desktop/src/features/agents/ui/PersonaAddedBy.tsx index 66e5ee31f9e..3cdec291046 100644 --- a/desktop/src/features/agents/ui/PersonaAddedBy.tsx +++ b/desktop/src/features/agents/ui/PersonaAddedBy.tsx @@ -2,13 +2,17 @@ import { cn } from "@/shared/lib/cn"; type PersonaAddedByProps = { className?: string; + label?: string; }; -export function PersonaAddedBy({ className }: PersonaAddedByProps) { +export function PersonaAddedBy({ + className, + label = "You", +}: PersonaAddedByProps) { return (

Added by{" "} - You + {label}

); } diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 0d6b5583ff8..ba76d6e4edb 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; +import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; @@ -11,6 +12,8 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Markdown } from "@/shared/ui/markdown"; import { Skeleton } from "@/shared/ui/skeleton"; +import agentOutlineUrl from "../assets/agent-outline.svg"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; import { PersonaAddedBy } from "./PersonaAddedBy"; import { personaCatalogCopy } from "./personaLibraryCopy"; @@ -28,7 +31,7 @@ type PersonaCatalogDialogProps = { }; const agentInstructionMarkdownClassName = [ - "mt-3 leading-6 text-muted-foreground [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", + "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground", "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground", "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground", @@ -100,7 +103,7 @@ export function PersonaCatalogDialog({ +
+ +

+ {personaCatalogCopy.emptyCatalogTitle} +

+

+ {personaCatalogCopy.emptyCatalogDescription} +

+
+
+ ); + } + return (
@@ -200,9 +228,9 @@ function PersonaCatalogChooser({
-
+
{isLoading ? : null} @@ -211,19 +239,6 @@ function PersonaCatalogChooser({ ) : null} - {!isLoading && personas.length === 0 && !error ? ( -
-
-

- {personaCatalogCopy.emptyCatalogTitle} -

-

- {personaCatalogCopy.emptyCatalogDescription} -

-
-
- ) : null} - {error ? (

{error.message} @@ -263,7 +278,7 @@ function PersonaCatalogChooser({ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { return ( -

+
{persona.displayName} - {persona.isBuiltIn ? null : } + {persona.isBuiltIn ? null : ( + + )}
- -
+

Agent instruction

@@ -309,36 +322,6 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { ); } -function PersonaCatalogMetaGroup({ - items, -}: { - items: { label: string; value: string }[]; -}) { - return ( -
-
- {items.map((item, index) => ( -
0 && - "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", - )} - key={item.label} - > -

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- ); -} - function PersonaCatalogListSkeleton() { return (
diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index b6d3fafd3ce..c641de9c709 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { AlertCircle, + BookUser, Check, ChevronRight, Download, @@ -11,6 +12,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; +import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogRelay"; import { useOpenDmMutation, useUpsertCachedChannel, @@ -20,7 +22,6 @@ import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri"; import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia"; import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; import type { AgentPersona, UserSearchResult } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; import { AlertDialog, AlertDialogAction, @@ -39,7 +40,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; import { @@ -51,8 +51,11 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + catalogShareLevel: CatalogPersonaShareLevel; isPending: boolean; linkedAgentPubkey: string | null; + effectiveAvatarUrl: string | null; + onCatalogShareLevelChange: (shareLevel: CatalogPersonaShareLevel) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -60,6 +63,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { + afterLink?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -109,6 +113,20 @@ type PendingMemoryShare = { recipientNames?: string[]; }; +function buildSnapshotShareLevels(itemLabel: "Agent" | "Team") { + return [ + { value: "none" as const, label: `${itemLabel} only` }, + { + value: "core" as const, + label: `${itemLabel} + core memory`, + }, + { + value: "everything" as const, + label: `${itemLabel} + all memories`, + }, + ]; +} + function formatRecipientAudience(names: readonly string[]): string { if (names.length === 0) return "The people you selected"; if (names.length === 1) return names[0] ?? "The person you selected"; @@ -179,39 +197,32 @@ function MemoryShareConfirmation({ function ShareLevelControl({ ariaLabel, - className, disabled, hasMemoryOptions, - onOpenChange, - staticClassName, - staticLabel, testId, value, options, onChange, }: { ariaLabel: string; - className?: string; disabled: boolean; hasMemoryOptions: boolean; - onOpenChange?: (open: boolean) => void; - staticClassName?: string; - staticLabel: string; testId: string; value: SnapshotMemoryLevel; options: { value: SnapshotMemoryLevel; label: string }[]; onChange: (level: SnapshotMemoryLevel) => void; }) { if (!hasMemoryOptions) { + // Nothing to choose from, so there is no dropdown to open. State the + // outcome rather than naming the sole option: the memory-level labels + // ("Agent only", "+ core memory", …) are comparative and only make sense + // when the alternatives are actually offered. return ( - {staticLabel} + No memories included ); } @@ -219,9 +230,7 @@ function ShareLevelControl({ return ( onChange(nextValue as SnapshotMemoryLevel)} options={options} testId={testId} @@ -231,6 +240,7 @@ function ShareLevelControl({ } export function SnapshotShareDialog({ + afterLink, displayName, encodeSnapshot, hasMemoryOptions, @@ -252,9 +262,7 @@ export function SnapshotShareDialog({ const [copyStatus, setCopyStatus] = React.useState("idle"); const [pendingMemoryShare, setPendingMemoryShare] = React.useState(null); - const [linkShareLevel, setLinkShareLevel] = - React.useState("none"); - const [recipientShareLevel, setRecipientShareLevel] = + const [shareLevel, setShareLevel] = React.useState("none"); const encodedSnapshotCacheRef = React.useRef( new Map>(), @@ -273,9 +281,7 @@ export function SnapshotShareDialog({ const isActionPending = isPending || isCopying || isSending; const isInterfacePending = isPending || isSending; const hasSelectedRecipients = selectedRecipients.length > 0; - const showMemoryWarning = - linkShareLevel !== "none" || - (hasSelectedRecipients && recipientShareLevel !== "none"); + const showMemoryWarning = shareLevel !== "none"; const recipientActionTransition = shouldReduceMotion ? { duration: 0 } : RECIPIENT_ACTION_TRANSITION; @@ -298,17 +304,7 @@ export function SnapshotShareDialog({ const itemLabel = snapshotKind === "team" ? "team" : "agent"; const itemLabelTitle = snapshotKind === "team" ? "Team" : "Agent"; const shareLevels = React.useMemo( - () => [ - { value: "none" as const, label: `${itemLabelTitle} only` }, - { - value: "core" as const, - label: `${itemLabelTitle} + core memory`, - }, - { - value: "everything" as const, - label: `${itemLabelTitle} + all memories`, - }, - ], + () => buildSnapshotShareLevels(itemLabelTitle), [itemLabelTitle], ); const getEncodedSnapshot = React.useCallback( @@ -337,8 +333,7 @@ export function SnapshotShareDialog({ setSelectedRecipients([]); setCopyStatus("idle"); setPendingMemoryShare(null); - setLinkShareLevel("none"); - setRecipientShareLevel("none"); + setShareLevel("none"); onReset?.(); snapshotSendController.reset(); } @@ -495,21 +490,6 @@ export function SnapshotShareDialog({ excludedPubkeys={excludedRecipientPubkeys} onSelectionChange={setSelectedRecipients} open={open} - renderEndControl={(handleAccessOpenChange) => ( - - )} selectedUsers={selectedRecipients} testIdPrefix={testIdPrefix} /> @@ -532,9 +512,7 @@ export function SnapshotShareDialog({ isActionPending || !snapshotSendController.isDmSafetyReady } - onClick={() => - requestMemoryShare("send", recipientShareLevel) - } + onClick={() => requestMemoryShare("send", shareLevel)} type="button" > {isSending ? "Sending…" : "Send"} @@ -552,6 +530,116 @@ export function SnapshotShareDialog({

+
+ + + +
+

Share with a link

+

+ Anyone with the link can add and use a copy. +

+
+ +
+ +
+

+ What’s included +

+ +
+ {showMemoryWarning ? ( -
-
- - - -
-

Share with a link

-

- Anyone with the link can add and use a copy. -

-
- -
- -
- -
-
+ {afterLink}
- {selectedUsers.length > 0 && renderEndControl - ? renderEndControl((controlOpen) => { - if (controlOpen) setIsPickerOpen(false); - }) - : null}
0 ? 1 : 0); if (visiblePersonas.length === 0 && overflowCount === 0) { return ( @@ -130,16 +131,26 @@ function TeamAvatarRow({
{visiblePersonas.map((persona, index) => ( - + ))} {overflowCount > 0 ? ( - - +{overflowCount} - +
0 ? "-ml-5" : ""} + style={{ zIndex: stackItemCount }} + > + + +{overflowCount} + +
) : null}
@@ -148,25 +159,39 @@ function TeamAvatarRow({ function TeamAvatarItem({ index, + isFollowedByAnother, persona, }: { index: number; + isFollowedByAnother: boolean; persona: AgentPersona; }) { const avatarUrl = persona.avatarUrl?.trim() ?? null; return ( -
+
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 ? ( ) : ( - + - - Import team snapshot + Import diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index f24d6a41ffc..9bbe3feef79 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -45,7 +45,6 @@ type UnifiedAgentsSectionProps = { onOpenPersonaProfile: (persona: AgentPersona) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; - canChooseCatalog: boolean; personas: AgentPersona[]; personasError: Error | null; personaFeedbackErrorMessage: string | null; @@ -53,12 +52,13 @@ type UnifiedAgentsSectionProps = { isPersonasLoading: boolean; isPersonasPending: boolean; onCreatePersona: () => void; - onChooseCatalog: () => void; + onDiscoverPersonas: () => void; onDuplicatePersona: (persona: AgentPersona) => void; onEditPersona: (persona: AgentPersona) => void; onSharePersona: ( persona: AgentPersona, linkedAgent: ManagedAgent | undefined, + effectiveAvatarUrl: string | null, ) => void; onDeactivatePersona: (persona: AgentPersona) => void; onDeletePersona: (persona: AgentPersona) => void; @@ -66,7 +66,9 @@ type UnifiedAgentsSectionProps = { }; const AGENT_CARD_COLUMN_CLASS = "w-full"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; +export const AGENT_CARD_GRID_COLUMNS_CLASS = + "grid-cols-[repeat(auto-fill,minmax(220px,240px))]"; +const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { @@ -83,7 +85,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onOpenPersonaProfile, onStartAgent, onStartPersona, - canChooseCatalog, personas, personasError, personaFeedbackErrorMessage, @@ -91,7 +92,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPersonasLoading, isPersonasPending, onCreatePersona, - onChooseCatalog, + onDiscoverPersonas, onDuplicatePersona, onEditPersona, onSharePersona, @@ -157,9 +158,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const profileAgent = pickProfileAgent(group.agents); return ( ( + onSharePersona(persona, linkedAgent, effectiveAvatarUrl) + } /> - } + )} agent={profileAgent} defaultModel={defaultModel} key={group.persona.id} @@ -184,11 +189,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); })}
@@ -251,7 +255,10 @@ function AgentPersonaCard({ onStartAgent, onStartPersona, }: { - actions?: React.ReactNode; + actions?: ( + effectiveAvatarUrl: string | null, + isEffectiveAvatarLoading: boolean, + ) => React.ReactNode; agent: ManagedAgent | undefined; defaultModel: string; persona: AgentPersona; @@ -283,7 +290,10 @@ function AgentPersonaCard({ return ( void; - onChooseCatalog: () => void; - onCreatePersona: () => void; + isPending: boolean; + onCreate: () => void; + onDiscover: () => void; + onImport: () => void; }) { return ( - + event.preventDefault()} > - - Create from scratch + + Create agent + + + Discover agents - {canChooseCatalog ? ( - - Choose from catalog - - ) : null} - Import agent snapshot + Import diff --git a/desktop/src/features/agents/ui/addCustomHarness.test.mjs b/desktop/src/features/agents/ui/addCustomHarness.test.mjs new file mode 100644 index 00000000000..6c0aa32daf6 --- /dev/null +++ b/desktop/src/features/agents/ui/addCustomHarness.test.mjs @@ -0,0 +1,344 @@ +/** + * Behavior tests for the inline "Add custom harness…" dropdown entry shared by + * AgentDefinitionDialog and AgentInstanceEditDialog. + * + * Two seams carry the feature, and both are pinned here: + * + * 1. ROUTING (`runtimeDropdownAction`) — the sentinel must resolve to "open + * the form", never to a selection. If it ever resolved to a selection the + * dialogs would write "\u0000add-custom-harness" into `runtime` and try to + * spawn an agent on a harness that does not exist. + * 2. DEFERRED SELECTION (`usePendingHarnessSelection`) — saving only writes + * the definition file; the harness becomes a catalog entry when the + * invalidated discovery query refetches. Selecting on save would pick an + * id no entry backs. The hook must wait for the catalog, fire exactly + * once, stay silent when the user cancels, and drop the pending id when + * its dialog closes — the host dialogs stay mounted, so a stale id would + * otherwise select into reset form state on a later publish. + * + * The hook is mounted for real (react-dom/client + act) rather than simulated, + * so its effect wiring — including the guard that survives the dialogs' + * non-memoized change handlers — is what gets tested. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Minimal DOM shim ───────────────────────────────────────────────────────── +// react-dom/client needs a container element and a document; node has neither. +// The harness renders null, so no real node operations are exercised. + +class ElementShim { + constructor() { + this.children = []; + this.childNodes = []; + this.nodeType = 1; + this.nodeName = "DIV"; + this.tagName = "DIV"; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + } + get ownerDocument() { + return globalThis.document; + } + addEventListener() {} + removeEventListener() {} + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + return child; + } + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + return child; + } + insertBefore(child) { + return this.appendChild(child); + } + contains(target) { + return this === target; + } +} + +globalThis.document = { + activeElement: null, + addEventListener() {}, + createElement: () => new ElementShim(), + get defaultView() { + return globalThis.window; + }, + nodeType: 9, + removeEventListener() {}, +}; +// react-dom derives update priority from window.event and walks iframe +// boundaries via window.HTMLIFrameElement during commit. +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + addEventListener() {}, + document: globalThis.document, + event: undefined, + HTMLIFrameElement: ElementShim, + removeEventListener() {}, + }, +}); +globalThis.HTMLElement = ElementShim; +globalThis.Node = ElementShim; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { NO_RUNTIME_DROPDOWN_VALUE } from "./agentConfigOptions.tsx"; +import { + ADD_CUSTOM_HARNESS_OPTION, + ADD_CUSTOM_HARNESS_VALUE, + readyHarnessId, + runtimeDropdownAction, + usePendingHarnessSelection, +} from "./addCustomHarness.ts"; + +// ── Routing: the sentinel opens the form, it is never a selection ──────────── + +test("selecting the add-custom entry requests the form and yields no runtime id", () => { + const action = runtimeDropdownAction(ADD_CUSTOM_HARNESS_VALUE); + assert.equal(action.kind, "add-custom-harness"); + // The dialogs read `action.runtimeId` on the select branch; the sentinel + // must not carry one, or it could leak into form state. + assert.equal("runtimeId" in action, false); +}); + +test("selecting a harness yields that harness id", () => { + assert.deepEqual(runtimeDropdownAction("my-harness"), { + kind: "select", + runtimeId: "my-harness", + }); +}); + +test("selecting the no-runtime entry yields the empty id", () => { + assert.deepEqual(runtimeDropdownAction(NO_RUNTIME_DROPDOWN_VALUE), { + kind: "select", + runtimeId: "", + }); +}); + +test("the add-custom sentinel cannot collide with a backend-valid harness id", () => { + // Backend ids match [a-z0-9_][a-z0-9_-]* (custom_harnesses.rs), so a + // NUL-prefixed value is unreachable as a real id. + assert.equal(ADD_CUSTOM_HARNESS_VALUE.startsWith("\u0000"), true); + assert.equal(ADD_CUSTOM_HARNESS_OPTION.value, ADD_CUSTOM_HARNESS_VALUE); + assert.equal(ADD_CUSTOM_HARNESS_OPTION.label, "Add custom harness…"); +}); + +// ── Readiness: an id is selectable only once the catalog publishes it ──────── + +test("a pending id absent from the catalog is not ready", () => { + assert.equal(readyHarnessId([{ id: "claude" }], "my-harness"), null); +}); + +test("a pending id present in the catalog is ready", () => { + assert.equal( + readyHarnessId([{ id: "claude" }, { id: "my-harness" }], "my-harness"), + "my-harness", + ); +}); + +test("no pending id is never ready even against a populated catalog", () => { + assert.equal(readyHarnessId([{ id: "claude" }], null), null); +}); + +// ── Deferred selection: mounted hook ───────────────────────────────────────── + +/** + * Mount the real hook over a mutable catalog. Returns the setter the dialogs + * call on save, a `setRuntimes` to simulate the discovery refetch, a `setOpen` + * to simulate the owning dialog closing and reopening, and the log of ids the + * hook handed back for selection. + */ +async function mountPendingSelection(initialRuntimes = []) { + const selected = []; + const control = {}; + + function Harness() { + const [runtimes, setRuntimes] = React.useState(initialRuntimes); + const [open, setOpen] = React.useState(true); + // Deliberately NOT memoized: both dialogs pass a plain function + // declaration, so `onReady` has a fresh identity on every render. + const onReady = (id) => selected.push(id); + control.save = usePendingHarnessSelection(runtimes, onReady, open); + control.setRuntimes = setRuntimes; + control.setOpen = setOpen; + return null; + } + + const root = createRoot(new ElementShim()); + await act(async () => { + root.render(React.createElement(Harness)); + }); + return { control, root, selected }; +} + +test("saving a harness selects it only once the catalog publishes it", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // Save returns before discovery refetches — nothing to select yet. + await act(async () => control.save("my-harness")); + assert.deepEqual(selected, []); + + // The invalidated discovery query resolves with the new entry. + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + assert.deepEqual(selected, ["my-harness"]); + + await act(async () => root.unmount()); +}); + +test("a published harness is selected exactly once across later catalog updates", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.save("my-harness")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + assert.deepEqual(selected, ["my-harness"]); + + // Any later refetch re-renders with a new array identity and a new onReady + // identity. Re-firing here would clobber a selection the user made in + // between, so the pending id must have been cleared. + await act(async () => + control.setRuntimes([ + { id: "claude" }, + { id: "my-harness" }, + { id: "codex" }, + ]), + ); + assert.deepEqual(selected, ["my-harness"]); + + await act(async () => root.unmount()); +}); + +test("cancelling the form leaves the current selection untouched", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // Cancel never reports a saved id, so no selection is ever requested — even + // as the catalog keeps refreshing underneath. + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "codex" }]), + ); + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("a saved harness discovery never publishes is never selected", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // e.g. the definition file was written but the entry failed to load. The + // hook must stall rather than select an id no catalog entry backs. + await act(async () => control.save("ghost-harness")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "codex" }]), + ); + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("two harnesses registered in a row are each selected when published", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.save("first")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "first" }]), + ); + await act(async () => control.save("second")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "first" }, { id: "second" }]), + ); + assert.deepEqual(selected, ["first", "second"]); + + await act(async () => root.unmount()); +}); + +test("a second save before the first publishes selects only the later harness", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // The dropdown holds one harness, so the latest registration wins: the + // first id is dropped rather than queued behind the second. + await act(async () => control.save("first")); + await act(async () => control.save("second")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "first" }, { id: "second" }]), + ); + assert.deepEqual(selected, ["second"]); + + await act(async () => root.unmount()); +}); + +// ── Lifecycle: a pending id never outlives the dialog that created it ──────── + +test("a harness published after its dialog closed is never selected", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // Both host dialogs stay mounted when closed, so the hook keeps running. + await act(async () => control.save("my-harness")); + await act(async () => control.setOpen(false)); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + + // Selecting here would write into form state the close already reset. + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("reopening after closing mid-registration does not select the abandoned harness", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.save("my-harness")); + await act(async () => control.setOpen(false)); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + // The reopened dialog seeds from its own initial values; a stale pending id + // must not overwrite them. + await act(async () => control.setOpen(true)); + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("a harness saved after reopening is still selected when published", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.setOpen(false)); + await act(async () => control.setOpen(true)); + await act(async () => control.save("my-harness")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + assert.deepEqual(selected, ["my-harness"]); + + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/agents/ui/addCustomHarness.ts b/desktop/src/features/agents/ui/addCustomHarness.ts new file mode 100644 index 00000000000..f9c21435300 --- /dev/null +++ b/desktop/src/features/agents/ui/addCustomHarness.ts @@ -0,0 +1,104 @@ +/** + * Shared pieces of the inline "Add custom harness…" entry the agent dialogs + * append to their harness dropdown. + * + * Registering a custom harness used to be reachable only from Settings, so + * anyone whose first stop was "New agent" never learned the path existed. + * These helpers keep the entry identical across the dropdowns, keep its + * sentinel value out of form state, and defer selecting a freshly registered + * harness until discovery has actually published it. + */ + +import * as React from "react"; + +import { + NO_RUNTIME_DROPDOWN_VALUE, + type PersonaDropdownOption, +} from "./agentConfigOptions"; + +/** + * Dropdown value for the add-custom-harness entry. NUL-prefixed so it can + * never collide with a harness id (`[a-z0-9_][a-z0-9_-]*`) — same trick as the + * harness catalog's `CUSTOM_ENTRY_ID`. + */ +export const ADD_CUSTOM_HARNESS_VALUE = "\u0000add-custom-harness"; + +export const ADD_CUSTOM_HARNESS_OPTION: PersonaDropdownOption = { + label: "Add custom harness…", + value: ADD_CUSTOM_HARNESS_VALUE, +}; + +export type RuntimeDropdownAction = + | { kind: "add-custom-harness" } + | { kind: "select"; runtimeId: string }; + +/** + * Route a harness-dropdown change. The add-custom entry only opens the + * registration form — it is never a selection, so its sentinel can't reach + * form state. Every other value selects, with the no-runtime sentinel + * normalized to the empty id. + */ +export function runtimeDropdownAction(value: string): RuntimeDropdownAction { + if (value === ADD_CUSTOM_HARNESS_VALUE) { + return { kind: "add-custom-harness" }; + } + return { + kind: "select", + runtimeId: value === NO_RUNTIME_DROPDOWN_VALUE ? "" : value, + }; +} + +/** + * The pending harness id once discovery has published it, else `null`. + * + * Saving only writes the definition file — the harness becomes a catalog entry + * when the invalidated discovery query refetches. Selecting before then would + * pick an id no entry backs: the create dialog would block Save on an unknown + * availability, and the instance dialog could not read the command to pin. + */ +export function readyHarnessId( + runtimes: ReadonlyArray<{ id: string }>, + pendingId: string | null, +): string | null { + return runtimes.some((runtime) => runtime.id === pendingId) + ? pendingId + : null; +} + +/** + * Selects a newly registered custom harness once discovery publishes it. + * + * Returns the setter to hand the saved id; `onReady` then fires with it, so + * callers reuse their normal dropdown-change path instead of growing a second + * selection code path. + * + * `active` is the owning dialog's open state. The wait is only meaningful + * while that dialog is open: both host dialogs stay mounted across closes, so + * a pending id would otherwise survive the close and select into reset — or + * hidden — form state whenever discovery caught up. Going inactive both blocks + * `onReady` and drops the pending id, so a later publish is a no-op and + * reopening starts clean. A second save before the first publishes replaces + * it: the field holds one harness, so the latest save wins. + */ +export function usePendingHarnessSelection( + runtimes: ReadonlyArray<{ id: string }>, + onReady: (id: string) => void, + active: boolean, +): (id: string) => void { + const [pendingId, setPendingId] = React.useState(null); + // Gated at render, not just in the effect, so a catalog update landing in + // the same commit as the close cannot slip a selection through. + const readyId = active ? readyHarnessId(runtimes, pendingId) : null; + + React.useEffect(() => { + if (!active) { + setPendingId(null); + return; + } + if (readyId === null) return; + setPendingId(null); + onReady(readyId); + }, [active, onReady, readyId]); + + return setPendingId; +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cba..1313d2cec41 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -426,6 +426,60 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { return `${runtime.label}${suffix}`; } +export function buildPersonaRuntimeDropdownOptions({ + defaultRuntimeId, + isCreateMode, + runtime, + runtimes, + runtimesLoading, +}: { + defaultRuntimeId?: string; + isCreateMode: boolean; + runtime: string; + runtimes: AcpRuntimeCatalogEntry[]; + runtimesLoading: boolean; +}): { + blankRuntimeOptionLabel: string; + runtimeDropdownOptions: PersonaDropdownOption[]; +} { + const blankRuntimeOptionLabel = runtimesLoading + ? "Loading harnesses..." + : isCreateMode + ? "Choose a harness" + : "No preference (use app default)"; + const runtimeDropdownOptions: PersonaDropdownOption[] = [ + ...(!isCreateMode + ? [ + { + label: blankRuntimeOptionLabel, + value: NO_RUNTIME_DROPDOWN_VALUE, + }, + ] + : []), + ...sortPersonaRuntimes(runtimes).map((candidate) => ({ + disabled: + isCreateMode && + defaultRuntimeId !== undefined && + candidate.availability !== "available", + label: `${formatRuntimeOptionLabel(candidate)}${ + isCreateMode && candidate.id === defaultRuntimeId ? " (default)" : "" + }`, + value: candidate.id, + })), + ]; + const currentRuntime = runtime.trim(); + if ( + currentRuntime.length > 0 && + !runtimeDropdownOptions.some((option) => option.value === currentRuntime) + ) { + runtimeDropdownOptions.push({ + label: `${currentRuntime} (current)`, + value: currentRuntime, + }); + } + return { blankRuntimeOptionLabel, runtimeDropdownOptions }; +} + function runtimeAvailabilitySortRank( availability: AcpRuntimeCatalogEntry["availability"], ) { diff --git a/desktop/src/features/agents/ui/effortTable.fixture.json b/desktop/src/features/agents/ui/effortTable.fixture.json index ed44c7581bb..defb1f86de2 100644 --- a/desktop/src/features/agents/ui/effortTable.fixture.json +++ b/desktop/src/features/agents/ui/effortTable.fixture.json @@ -41,6 +41,13 @@ "validValues": ["low", "medium", "high", "xhigh", "max"], "defaultValue": "high" }, + { + "note": "Anthropic adaptive xhigh-capable: claude-opus-5", + "provider": "anthropic", + "model": "claude-opus-5", + "validValues": ["low", "medium", "high", "xhigh", "max"], + "defaultValue": "high" + }, { "note": "Anthropic adaptive xhigh-capable: claude-mythos-5", "provider": "anthropic", diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 53c5e7a16f4..79ddad1c3ce 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -14,14 +14,13 @@ export const personaLibraryCopy = { export const personaCatalogCopy = { title: "Agent Catalog", - description: "Browse built-in agents and add them to My Agents.", + description: "Browse agents shared to this relay.", dialogTitle: "Agent Catalog", - dialogDescription: "Browse built-in agents and add them to My Agents.", + dialogDescription: "Browse agents shared to this relay.", emptyTitle: "You're all set", emptyDescription: "Everything in Agent Catalog is already in My Agents.", - emptyCatalogDescription: - "New agents will show up here when the app ships more options.", - emptyCatalogTitle: "No agents in the catalog yet", + emptyCatalogDescription: "Shared agents will appear here.", + emptyCatalogTitle: "No agents are being shared", detailsAction: "View details", selectAction: "Choose", deselectAction: "Deselect", diff --git a/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs b/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs index 611d5ff4c86..35067143092 100644 --- a/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs +++ b/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs @@ -16,7 +16,8 @@ function entry(overrides) { modelEnvVar: null, providerEnvVar: null, thinkingEnvVar: null, - installHint: "Install the amp-acp npm adapter: npm install -g amp-acp.", + installHint: + "Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.", installInstructionsUrl: "https://example.com", canAutoInstall: false, requiresExternalCli: false, @@ -43,7 +44,7 @@ test("not-installed warning includes the install hint", () => { const warning = runtimeAvailabilityWarning(entry({})); assert.equal( warning, - "Amp is not installed. Install the amp-acp npm adapter: npm install -g amp-acp.", + "Amp is not installed. Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.", ); }); @@ -62,7 +63,7 @@ test("adapter-missing warning names the adapter and includes the hint", () => { warning ?? "", /CLI is installed but the ACP adapter is missing/, ); - assert.match(warning ?? "", /npm install -g amp-acp/); + assert.match(warning ?? "", /amp-acp command is on your PATH/); }); test("cli-missing external-CLI warning keeps the hint", () => { diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 54535d121c9..2c7668969a1 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -17,9 +17,26 @@ import { type AgentSnapshotImportPreview, type AgentSnapshotImportResult, } from "@/features/agents/hooks"; -import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; -import { clearLegacyPersonaCatalogVisibility } from "@/features/agents/lib/legacyPersonaCatalogVisibility"; +import { + getLibraryPersonas, + getPersonaLabelsById, +} from "@/features/agents/lib/catalog"; +import { + type CatalogPersonaShareLevel, + catalogPersonasFromPublications, + findLocalPersonaForCatalogEntry, + isCatalogPersona, +} from "@/features/agents/lib/personaCatalogRelay"; +import { + usePersonaCatalogLiveUpdates, + usePersonaCatalogQuery, + useSetPersonaCatalogSharedMutation, + useUpdatePersonaAndPublishMutation, +} from "@/features/agents/lib/usePersonaCatalogRelay"; +import { personaSaveNotice } from "@/features/agents/lib/personaSaveNotice"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -51,7 +68,14 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const communityId = activeCommunity?.id ?? null; const personasQuery = usePersonasQuery(); + const catalogQuery = usePersonaCatalogQuery(communityId); + usePersonaCatalogLiveUpdates(communityId); + const setCatalogSharedMutation = + useSetPersonaCatalogSharedMutation(communityId); const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = React.useState(false); const acpRuntimesQuery = useAcpRuntimesQuery({ @@ -60,6 +84,8 @@ export function usePersonaActions() { const createAgentMutation = useCreateManagedAgentMutation(); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); + const updatePersonaAndPublishMutation = + useUpdatePersonaAndPublishMutation(communityId); const deletePersonaMutation = useDeletePersonaMutation(); const setPersonaActiveMutation = useSetPersonaActiveMutation(); const exportAgentSnapshotMutation = useExportAgentSnapshotMutation(); @@ -73,10 +99,12 @@ export function usePersonaActions() { const [personaToShare, setPersonaToShare] = React.useState<{ persona: AgentPersona; linkedAgentPubkey: string | null; + effectiveAvatarUrl: string | null; } | null>(null); const [personaToExportSnapshot, setPersonaToExportSnapshot] = React.useState<{ persona: AgentPersona; linkedAgentPubkey: string | null; + effectiveAvatarUrl: string | null; } | null>(null); const [snapshotImportState, setSnapshotImportState] = React.useState<{ fileBytes: number[]; @@ -101,9 +129,15 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - React.useEffect(() => { - clearLegacyPersonaCatalogVisibility(); - }, []); + const publications = catalogQuery.data ?? []; + const sharedCatalogPersonaIdSet = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey.toLowerCase(); + return new Set( + publications + .filter((publication) => publication.ownerPubkey === currentPubkey) + .map((publication) => publication.sourcePersonaId), + ); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -112,8 +146,21 @@ export function usePersonaActions() { ), [acpRuntimesQuery.data], ); - const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas), + const catalogPersonas = React.useMemo( + () => + catalogPersonasFromPublications( + publications, + personas, + identityQuery.data?.pubkey, + ), + [identityQuery.data?.pubkey, personas, publications], + ); + const libraryPersonas = React.useMemo( + () => getLibraryPersonas(personas), + [personas], + ); + const personaLabelsById = React.useMemo( + () => getPersonaLabelsById(personas), [personas], ); @@ -130,6 +177,7 @@ export function usePersonaActions() { intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, targetChannel?: Pick | null, + options?: { publishCatalogUpdates?: boolean }, ): Promise { if (isPersonaSubmitPending) { return false; @@ -139,8 +187,24 @@ export function usePersonaActions() { setIsPersonaSubmitPending(true); try { if ("id" in input) { - await updatePersonaMutation.mutateAsync(input); - setPersonaNoticeMessage(`Updated ${input.displayName}.`); + // "Save and publish" promises the community catalog sees this edit, so + // it must use the command that awaits the relay. A plain save only + // enqueues the head and cannot report the outcome. + if (options?.publishCatalogUpdates) { + const result = + await updatePersonaAndPublishMutation.mutateAsync(input); + if (result.publicationStatus === "queued" && result.relayMessage) { + console.warn( + `[updatePersonaAndPublish] relay publication queued: ${result.relayMessage}`, + ); + } + setPersonaNoticeMessage( + personaSaveNotice(input.displayName, result.publicationStatus), + ); + } else { + await updatePersonaMutation.mutateAsync(input); + setPersonaNoticeMessage(personaSaveNotice(input.displayName, null)); + } } else { const runtime = availableRuntimes.find( (candidate) => candidate.id === input.runtime, @@ -240,7 +304,46 @@ export function usePersonaActions() { ) { clearFeedback(surface); try { - await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + if (active && isCatalogPersona(persona)) { + const localPersona = findLocalPersonaForCatalogEntry( + personas, + persona.catalogSource, + ); + + if (localPersona) { + if (!localPersona.isActive) { + await setPersonaActiveMutation.mutateAsync({ + id: localPersona.id, + active: true, + }); + } + } else { + await createPersonaMutation.mutateAsync({ + displayName: persona.displayName, + avatarUrl: persona.avatarUrl ?? undefined, + systemPrompt: persona.systemPrompt, + runtime: persona.runtime ?? undefined, + model: persona.model ?? undefined, + provider: persona.provider ?? undefined, + namePool: persona.namePool, + behavior: { + respondTo: + persona.respondTo === "anyone" ? "anyone" : "owner-only", + parallelism: persona.parallelism ?? undefined, + }, + // Provenance on the copy: without it the copy's fresh local id is + // the only identifier, and the catalog offers "Add" again. + catalogSource: persona.catalogSource.isOwn + ? undefined + : { + ownerPubkey: persona.catalogSource.ownerPubkey, + personaId: persona.catalogSource.personaId, + }, + }); + } + } else { + await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + } setPersonaNoticeMessage( active ? `Selected ${persona.displayName} for My Agents.` @@ -334,6 +437,7 @@ export function usePersonaActions() { function openCatalog() { clearFeedback("catalog"); + void catalogQuery.refetch(); setIsCatalogDialogOpen(true); } @@ -345,17 +449,20 @@ export function usePersonaActions() { function openShare( persona: AgentPersona, linkedAgent: ManagedAgent | undefined, + effectiveAvatarUrl: string | null, ) { clearFeedback("library"); setPersonaToShare({ persona, linkedAgentPubkey: linkedAgent?.pubkey ?? null, + effectiveAvatarUrl, }); } function handleExportSnapshot( persona: AgentPersona, linkedAgentPubkey: string | null, + effectiveAvatarUrl: string | null, memoryLevel: SnapshotMemoryLevel, format: SnapshotFormat, ) { @@ -367,7 +474,7 @@ export function usePersonaActions() { memoryLevel, format, memorySourcePubkey: linkedAgentPubkey, - avatarUrl: persona.avatarUrl, + avatarUrl: effectiveAvatarUrl, }, { onSuccess: (saved) => { @@ -386,22 +493,83 @@ export function usePersonaActions() { ); } + function getPersonaCatalogShareLevel( + persona: AgentPersona, + ): CatalogPersonaShareLevel { + return persona.shared ? "none" : "not-shared"; + } + + async function setPersonaCatalogShareLevel( + persona: AgentPersona, + shareLevel: CatalogPersonaShareLevel, + ): Promise { + if (persona.isBuiltIn) return; + + clearFeedback("library"); + try { + const shared = shareLevel !== "not-shared"; + const result = await setCatalogSharedMutation.mutateAsync({ + id: persona.id, + shared, + }); + setPersonaToShare((current) => + current?.persona.id === result.persona.id + ? { ...current, persona: result.persona } + : current, + ); + if (result.publicationStatus === "queued") { + if (shared) { + setPersonaNoticeMessage( + `Sharing ${persona.displayName} is queued. It will appear after the relay accepts the update.`, + ); + } else { + setPersonaNoticeMessage( + `Removing ${persona.displayName} is queued. It may remain discoverable until the relay accepts the update.`, + ); + } + if (result.relayMessage) { + console.warn( + `[setPersonaShared] relay publication queued: ${result.relayMessage}`, + ); + } + } else if (!shared) { + setPersonaNoticeMessage( + `${persona.displayName} is no longer discoverable in the community catalog.`, + ); + } else { + setPersonaNoticeMessage( + `Published ${persona.displayName} to the community catalog.`, + ); + } + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to update catalog sharing.", + ); + } + } + const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || createAgentMutation.isPending || updatePersonaMutation.isPending || + updatePersonaAndPublishMutation.isPending || deletePersonaMutation.isPending || setPersonaActiveMutation.isPending || exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || - confirmSnapshotImportMutation.isPending; + confirmSnapshotImportMutation.isPending || + setCatalogSharedMutation.isPending; return { personasQuery, + catalogQuery, acpRuntimesQuery, createPersonaMutation, updatePersonaMutation, + updatePersonaAndPublishMutation, setPersonaActiveMutation, catalogPersonas, libraryPersonas, @@ -431,6 +599,9 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, + getPersonaCatalogShareLevel, + setPersonaCatalogShareLevel, + sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, snapshotImportResult, diff --git a/desktop/src/features/channels/channelSnapshot.test.mjs b/desktop/src/features/channels/channelSnapshot.test.mjs index 37b69f1eb21..5355bfe6f17 100644 --- a/desktop/src/features/channels/channelSnapshot.test.mjs +++ b/desktop/src/features/channels/channelSnapshot.test.mjs @@ -87,6 +87,36 @@ test("remove clears the snapshot for that relay", () => { assert.equal(readChannelSnapshot(RELAY), null); }); +test("cache write evicts disposable entries and retries at quota", () => { + const original = window.localStorage; + const storage = new Map([ + ["buzz-channel-messages.v1:relay:old", "big"], + ["buzz-timeline-skeleton-shape.v1:old", "small"], + ]); + window.localStorage = { + get length() { + return storage.size; + }, + key: (index) => [...storage.keys()][index] ?? null, + getItem: (key) => storage.get(key) ?? null, + setItem(key, value) { + if (!storage.has(key) && storage.size >= 2) { + throw new Error("quota exceeded"); + } + storage.set(key, value); + }, + removeItem: (key) => storage.delete(key), + }; + try { + writeChannelSnapshot(RELAY, [makeChannel()]); + assert.deepEqual(readChannelSnapshot(RELAY), [makeChannel()]); + assert.equal(storage.has("buzz-channel-messages.v1:relay:old"), false); + assert.equal(storage.has("buzz-timeline-skeleton-shape.v1:old"), false); + } finally { + window.localStorage = original; + } +}); + test("write is tolerant of storage failures", () => { const original = window.localStorage.setItem; window.localStorage.setItem = () => { diff --git a/desktop/src/features/channels/channelSnapshot.ts b/desktop/src/features/channels/channelSnapshot.ts index 404377eee3c..265b675790e 100644 --- a/desktop/src/features/channels/channelSnapshot.ts +++ b/desktop/src/features/channels/channelSnapshot.ts @@ -13,6 +13,7 @@ import type { Channel } from "@/shared/api/types"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; const STORAGE_KEY_PREFIX = "buzz-channels.v1"; @@ -51,9 +52,22 @@ export function writeChannelSnapshot( ): void { try { const key = channelSnapshotKey(relayUrl); - const serialized = JSON.stringify({ version: 1, channels }); - if (window.localStorage.getItem(key) === serialized) return; - window.localStorage.setItem(key, serialized); + const previous = window.localStorage.getItem(key); + if (previous) { + try { + const parsed = parseChannelSnapshot(JSON.parse(previous)); + if (parsed && JSON.stringify(parsed) === JSON.stringify(channels)) + return; + } catch { + // Malformed snapshots are replaced below. + } + } + const serialized = JSON.stringify({ + version: 1, + updatedAt: Date.now(), + channels, + }); + setLocalStorageItemWithRecovery(key, serialized); } catch { // Storage access failures are non-fatal. } diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 21ab04f8d6b..d685a961030 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -164,7 +164,7 @@ export function BotActivityComposerAction({ className={cn( "inline-flex items-center justify-center rounded-full border border-border/60 bg-background font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary", isInline - ? "h-7 min-w-0 gap-2 overflow-visible border-transparent bg-transparent px-0 text-xs font-semibold leading-none shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" + ? "min-w-0 gap-1.5 overflow-visible border-transparent bg-transparent px-0 text-xs font-normal leading-normal shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" : "h-9 min-w-9 gap-1.5 px-2 text-xs", )} data-testid="bot-activity-composer-trigger" @@ -178,17 +178,16 @@ export function BotActivityComposerAction({ onMouseLeave={closeWithDelay} type="button" > - + {workingAgents.slice(0, 2).map((agent) => ( @@ -201,11 +200,15 @@ export function BotActivityComposerAction({ ) : null} {isInline ? ( - {visibleStatusLabel} + + {visibleStatusLabel} + ) : ( "working" )} diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx new file mode 100644 index 00000000000..216b205fcc6 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx @@ -0,0 +1,66 @@ +import type { ComponentProps } from "react"; + +import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory"; +import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; + +type ChannelComposerActivityAccessoryProps = { + agents: ComponentProps["agents"]; + channel: ComponentProps["channel"]; + currentPubkey: ComponentProps["currentPubkey"]; + onOpenAgentSession: ComponentProps< + typeof BotActivityComposerAction + >["onOpenAgentSession"]; + openAgentSessionPubkey: ComponentProps< + typeof BotActivityComposerAction + >["openAgentSessionPubkey"]; + profiles: ComponentProps["profiles"]; + typingPubkeys: string[]; + visible: boolean; + workingBotPubkeys: string[]; +}; + +export function ChannelComposerActivityAccessory({ + agents, + channel, + currentPubkey, + onOpenAgentSession, + openAgentSessionPubkey, + profiles, + typingPubkeys, + visible, + workingBotPubkeys, +}: ChannelComposerActivityAccessoryProps) { + return ( + +
+ {workingBotPubkeys.length > 0 ? ( +
+ +
+ ) : null} + {typingPubkeys.length > 0 ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index dbce0b6f5ea..92fa172ff5a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -3,6 +3,7 @@ import { Hash, LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; @@ -25,7 +26,6 @@ import { buildVideoReviewContextForMessage, } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; -import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; @@ -40,6 +40,7 @@ import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewMod import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory"; import { containsWelcomePersonaMention, WelcomeComposerBanner, @@ -218,6 +219,7 @@ export const ChannelPane = React.memo(function ChannelPane({ composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, "css-variable", + () => messageTimelineRef.current?.settleAtBottom() ?? false, ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { @@ -408,26 +410,18 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; + const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity; const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) { - return []; - } - - const pubkeys: string[] = []; - for (const entry of botTypingEntries) { - if (entry.threadHeadId !== openThreadHeadId) { - continue; - } - - if ( - !pubkeys.some( - (pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(), - ) - ) { - pubkeys.push(entry.pubkey); - } - } - return pubkeys; + 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 hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; @@ -514,8 +508,6 @@ export const ChannelPane = React.memo(function ChannelPane({ const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); - // Focus mode only replaces the wide split thread pane; narrow threads and - // other auxiliary panels keep their existing presentation. const useFocusThreadDrawer = threadViewMode === "focus" && useSplitAuxiliaryPane && @@ -526,6 +518,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ + activeThreadHeadId: threadHeadMessage?.id ?? null, externalScrollTargetId: threadScrollTargetId, onExternalTargetResolved: onThreadScrollTargetResolved, onModeChange: markExitComplete, @@ -734,7 +727,12 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-composer-overlay" ref={composerWrapperRef} > -
+
{timeoutState.active ? (
) : null} + -
-
- {hasComposerBotActivity ? ( -
- -
- ) : null} - {hasTypingActivity ? ( - - ) : null} -
-
+ {/* The activity accessory is anchored in the dock's reserved + bottom rail, so fading it cannot change the observed + overlay height or move the conversation. Its natural + content height remains responsive. */} +
)} {canDropInMainColumn && mainComposerMedia.isDragOver ? ( - + ) : null} ) : null} @@ -881,7 +867,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} - onScrollTargetResolved={resolveScrollTarget} + onScrollTargetResolved={() => resolveScrollTarget()} + onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} onUnfollowThread={onUnfollowThread} profiles={profiles} @@ -898,7 +885,8 @@ export const ChannelPane = React.memo(function ChannelPane({ )} threadReplyUnreadCounts={threadReplyUnreadCounts} threadTypingPubkeys={threadTypingPubkeys} - toolbarExtraActions={ + activityAccessoryVisible={hasThreadComposerBotActivity} + activityAccessoryContent={ hasThreadComposerBotActivity ? ( { + assert.deepEqual( + getResolvedThreadTargets({ + externalTargetId: "reply-b", + layoutTargetId: null, + }), + { resolveExternal: true, resolveLayout: false }, + ); + assert.deepEqual( + getResolvedThreadTargets({ + externalTargetId: null, + layoutTargetId: null, + }), + { resolveExternal: true, resolveLayout: false }, + ); +}); + +test("drops a captured layout target when the active thread closes or changes", () => { + const captured = { messageId: "reply-a", threadHeadId: "thread-a" }; + + assert.equal( + getScopedLayoutScrollTargetId({ + activeThreadHeadId: "thread-a", + layoutTarget: captured, + }), + "reply-a", + ); + assert.equal( + getScopedLayoutScrollTargetId({ + activeThreadHeadId: null, + layoutTarget: captured, + }), + null, + ); + const replacementLayoutTargetId = getScopedLayoutScrollTargetId({ + activeThreadHeadId: "thread-b", + layoutTarget: captured, + }); + assert.equal(replacementLayoutTargetId, null); + assert.deepEqual( + getResolvedThreadTargets({ + externalTargetId: "reply-b", + layoutTargetId: replacementLayoutTargetId, + }), + { resolveExternal: true, resolveLayout: false }, + "the stale anchor does not mask the replacement thread target", + ); +}); + test("returns null without a mounted thread body or visible message", () => { assert.equal(findTopVisibleThreadMessageId(null), null); assert.equal( diff --git a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts index 5493eada508..8dd41cfc51e 100644 --- a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts +++ b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts @@ -31,7 +31,25 @@ export function getResolvedThreadTargets({ }; } +type LayoutScrollTarget = { + messageId: string; + threadHeadId: string; +}; + +export function getScopedLayoutScrollTargetId({ + activeThreadHeadId, + layoutTarget, +}: { + activeThreadHeadId: string | null; + layoutTarget: LayoutScrollTarget | null; +}): string | null { + return layoutTarget?.threadHeadId === activeThreadHeadId + ? layoutTarget.messageId + : null; +} + type ThreadViewModeSwitchOptions = { + activeThreadHeadId: string | null; externalScrollTargetId: string | null; onExternalTargetResolved: () => void; onModeChange?: (mode: ThreadViewMode) => void; @@ -39,13 +57,23 @@ type ThreadViewModeSwitchOptions = { /** Preserves the reply being read while the thread changes presentation. */ export function useThreadViewModeSwitch({ + activeThreadHeadId, externalScrollTargetId, onExternalTargetResolved, onModeChange, }: ThreadViewModeSwitchOptions) { - const [layoutScrollTargetId, setLayoutScrollTargetId] = React.useState< - string | null - >(null); + const [layoutScrollTarget, setLayoutScrollTarget] = + React.useState(null); + const layoutScrollTargetId = getScopedLayoutScrollTargetId({ + activeThreadHeadId, + layoutTarget: layoutScrollTarget, + }); + + React.useEffect(() => { + setLayoutScrollTarget((current) => + current?.threadHeadId === activeThreadHeadId ? current : null, + ); + }, [activeThreadHeadId]); const changeThreadViewMode = React.useCallback( (mode: ThreadViewMode, restoreFocus: boolean) => { @@ -54,7 +82,11 @@ export function useThreadViewModeSwitch({ ); const anchorId = findTopVisibleThreadMessageId(body); - setLayoutScrollTargetId(anchorId); + setLayoutScrollTarget( + anchorId && activeThreadHeadId + ? { messageId: anchorId, threadHeadId: activeThreadHeadId } + : null, + ); onModeChange?.(mode); setThreadViewMode(mode); requestAnimationFrame(() => { @@ -69,17 +101,32 @@ export function useThreadViewModeSwitch({ }); }); }, - [onModeChange], + [activeThreadHeadId, onModeChange], ); - const resolveScrollTarget = React.useCallback(() => { - const resolution = getResolvedThreadTargets({ - externalTargetId: externalScrollTargetId, - layoutTargetId: layoutScrollTargetId, - }); - if (resolution.resolveLayout) setLayoutScrollTargetId(null); - if (resolution.resolveExternal) onExternalTargetResolved(); - }, [externalScrollTargetId, layoutScrollTargetId, onExternalTargetResolved]); + const resolveScrollTarget = React.useCallback( + (settledMessageId?: string) => { + const resolution = getResolvedThreadTargets({ + externalTargetId: externalScrollTargetId, + layoutTargetId: layoutScrollTargetId, + }); + if (resolution.resolveExternal) onExternalTargetResolved(); + if (settledMessageId) { + setLayoutScrollTarget((current) => + current?.threadHeadId === activeThreadHeadId && + current.messageId === settledMessageId + ? null + : current, + ); + } + }, + [ + activeThreadHeadId, + externalScrollTargetId, + layoutScrollTargetId, + onExternalTargetResolved, + ], + ); return { changeThreadViewMode, diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index f8db0af5e29..aeb3abb9059 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -88,6 +88,13 @@ export function isChannelUnreadTriggerKind(kind: number, isDmChannel: boolean) { : UNREAD_TRIGGER_KINDS.has(kind); } +export function isHomeActivityEvent( + isDmChannel: boolean, + isThreadedReply: boolean, +) { + return isThreadedReply || isDmChannel; +} + export function withChannelTagFallback( event: RelayEvent, channelId: string, @@ -278,7 +285,7 @@ export function useLiveChannelUpdates( } } else { options.onChannelMessage?.(channelId, event); - if (isThreadedReply) { + if (isHomeActivityEvent(isDmChannel, isThreadedReply)) { options.onThreadReplyNotification?.(channelId, event); } } diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx index 4c94acf102a..76837b65aac 100644 --- a/desktop/src/features/communities/ui/CommunityEditForm.tsx +++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx @@ -68,7 +68,7 @@ export function CommunityEditForm({ let cancelled = false; const timeoutId = window.setTimeout(() => { - void getJoinPolicy(normalizedUrl) + void getJoinPolicy(normalizedUrl, "native") .then((policy) => { if (cancelled || !policy) return; setJoinPolicy(policy); @@ -105,7 +105,7 @@ export function CommunityEditForm({ if (joinPolicyRequired) { try { - const policy = await getJoinPolicy(normalizedUrl); + const policy = await getJoinPolicy(normalizedUrl, "native"); if (!policy) { onSubmit(trimmedName, normalizedUrl); return; diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index bd23e2bbecb..9daca590f47 100644 --- a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; @@ -29,12 +30,14 @@ export function CommunityInviteDialog({ return ( - + Invite to community + + Anyone with this link can join this community. + diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 76c0262ea5c..dc0735c85e0 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -8,10 +8,8 @@ import { Button } from "@/shared/ui/button"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { Separator } from "@/shared/ui/separator"; @@ -24,6 +22,15 @@ const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "30 days", value: 30 * 24 * 60 * 60 }, ]; +const MAX_USE_OPTIONS: { label: string; value: number | null }[] = [ + { label: "No limit", value: null }, + { label: "1 use", value: 1 }, + { label: "3 uses", value: 3 }, + { label: "5 uses", value: 5 }, + { label: "10 uses", value: 10 }, + { label: "25 uses", value: 25 }, +]; + export const DEFAULT_INVITE_TTL_SECS = TTL_OPTIONS[1].value; type CopyStatus = "idle" | "copying" | "copied"; @@ -31,8 +38,9 @@ type CopyStatus = "idle" | "copying" | "copied"; /** * Share-with-link footer for the community invite dialog. * - * Each copy action mints a fresh stateless invite code and places its - * shareable landing-page URL on the clipboard. + * Each copy action mints a fresh database-backed invite code and places its + * shareable landing-page URL on the clipboard. Invites may be unlimited or + * capped to a caller-selected number of successful joins. */ export function InviteLinkSection({ onTtlSecsChange, @@ -42,8 +50,12 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); + const [maxUses, setMaxUses] = React.useState(null); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; + const maxUsesLabel = + MAX_USE_OPTIONS.find((option) => option.value === maxUses)?.label ?? + "No limit"; const copyLabel = copyStatus === "copying" ? "Copying…" @@ -61,7 +73,7 @@ export function InviteLinkSection({ if (copyStatus === "copying") return; setCopyStatus("copying"); try { - const invite = await mintInvite(ttlSecs); + const invite = await mintInvite({ ttlSecs, maxUses }); await writeTextToClipboard(invite.url); setCopyStatus("copied"); toast.success("Invite link copied"); @@ -73,50 +85,79 @@ export function InviteLinkSection({ return (
-
- - -
-

Share with a link

-

- Anyone with the link can join this community. -

+
+
+ Expires after + + + + + + onTtlSecsChange(Number(value))} + value={String(ttlSecs)} + > + {TTL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+
+ Limit number of uses + + + + + + + setMaxUses(value === "no-limit" ? null : Number(value)) + } + value={String(maxUses ?? "no-limit")} + > + {MAX_USE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
- - - - - - Expires after - - onTtlSecsChange(Number(value))} - value={String(ttlSecs)} - > - {TTL_OPTIONS.map((option) => ( - - {option.label} - - ))} - - -
diff --git a/desktop/src/features/home/lib/homeMessageCapabilities.ts b/desktop/src/features/home/lib/homeMessageCapabilities.ts new file mode 100644 index 00000000000..effd96e9346 --- /dev/null +++ b/desktop/src/features/home/lib/homeMessageCapabilities.ts @@ -0,0 +1,31 @@ +import type { InboxItem } from "@/features/home/lib/inbox"; + +export function getHomeMessageCapabilities( + item: InboxItem | null, + currentPubkey: string | undefined, + availableChannelIds: ReadonlySet, +) { + const canReact = Boolean( + item?.item.channelId && availableChannelIds.has(item.item.channelId), + ); + const canReply = + canReact && item?.item.kind !== 45001 && item?.item.kind !== 45003; + const disabledReplyReason = + canReply || !item + ? null + : item.item.channelId + ? availableChannelIds.has(item.item.channelId) + ? "This item does not support inline replies yet." + : "Open the linked channel to reply." + : "This inbox item does not have a reply target."; + + return { + canDelete: + item !== null && + currentPubkey?.trim().toLowerCase() === + item.item.pubkey.trim().toLowerCase(), + canReact, + canReply, + disabledReplyReason, + }; +} diff --git a/desktop/src/features/home/lib/homePaneLayout.ts b/desktop/src/features/home/lib/homePaneLayout.ts new file mode 100644 index 00000000000..dd4871c888f --- /dev/null +++ b/desktop/src/features/home/lib/homePaneLayout.ts @@ -0,0 +1,72 @@ +import { INBOX_COLUMN_MIN_WIDTH_PX } from "@/features/home/useResizableInboxListWidth"; + +type HomePaneLayoutOptions = { + hasAuxiliaryPane: boolean; + homeWidthPx: number; + inboxListWidthPx: number; + isDrafts: boolean; + isMessagesMode: boolean; + isNarrow: boolean; + isReminders: boolean; + isSinglePanelAuxiliaryView: boolean; + selectedDraft: boolean; + selectedEvent: boolean; + selectedReminder: boolean; + threadPanelWidthPx: number; +}; + +export function getHomePaneLayout(options: HomePaneLayoutOptions) { + const singleMessage = + options.isMessagesMode && + options.isNarrow && + options.selectedEvent && + !options.isSinglePanelAuxiliaryView; + const singleDraft = + options.isDrafts && + options.isNarrow && + options.selectedDraft && + !options.isSinglePanelAuxiliaryView; + const singleReminder = + options.isReminders && + options.isNarrow && + options.selectedReminder && + !options.isSinglePanelAuxiliaryView; + const showList = + !singleMessage && + !singleDraft && + !singleReminder && + !options.isSinglePanelAuxiliaryView; + const showDetail = + !options.isSinglePanelAuxiliaryView && + ((options.isMessagesMode && (!options.isNarrow || singleMessage)) || + (options.isDrafts && (!options.isNarrow || singleDraft)) || + (options.isReminders && (!options.isNarrow || singleReminder))); + const auxiliaryWidth = options.isSinglePanelAuxiliaryView + ? options.homeWidthPx + : options.threadPanelWidthPx; + const maxListWidth = + options.homeWidthPx > 0 + ? Math.max( + INBOX_COLUMN_MIN_WIDTH_PX, + options.homeWidthPx - + INBOX_COLUMN_MIN_WIDTH_PX - + (options.hasAuxiliaryPane ? auxiliaryWidth : 0), + ) + : undefined; + + return { + auxiliaryPaneWidthPx: auxiliaryWidth, + effectiveInboxListWidthPx: + options.homeWidthPx > 0 + ? Math.min( + options.inboxListWidthPx, + maxListWidth ?? options.inboxListWidthPx, + ) + : options.inboxListWidthPx, + isSinglePanelDetailView: singleMessage, + isSinglePanelDraftDetailView: singleDraft, + isSinglePanelReminderDetailView: singleReminder, + showDetailPane: showDetail, + showListPane: showList, + }; +} diff --git a/desktop/src/features/home/lib/inbox.test.mjs b/desktop/src/features/home/lib/inbox.test.mjs index 457e8ecdec5..cd2f78c80cf 100644 --- a/desktop/src/features/home/lib/inbox.test.mjs +++ b/desktop/src/features/home/lib/inbox.test.mjs @@ -3,11 +3,13 @@ import test from "node:test"; import { buildInboxItems, + findInboxItemByEventId, getInboxConversationId, getInboxTypeLabel, } from "./inbox.ts"; const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const DM_CHANNEL_ID = "8ad375a7-6990-4b22-985f-e3fd34f634d7"; const channels = [ { @@ -15,6 +17,11 @@ const channels = [ name: "buzz-bugs", channelType: "stream", }, + { + id: DM_CHANNEL_ID, + name: "dm-alice", + channelType: "dm", + }, ]; function feedWith(overrides) { @@ -161,6 +168,197 @@ test("thread groups use the latest row label even when the root was a mention", }); }); +test("thread groups resume at the oldest unread reply", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "reply-1", + category: "activity", + content: "Already read reply", + createdAt: 1, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "root-event", "", "reply"], + ], + }), + item({ + id: "reply-2", + category: "activity", + content: "First unread reply", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-1", "", "reply"], + ], + }), + item({ + id: "reply-3", + category: "activity", + content: "Newest unread reply", + createdAt: 3, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-2", "", "reply"], + ], + }), + ], + }), + getThreadReadAt: (rootId) => (rootId === "root-event" ? 1 : null), + }); + + assert.equal(inboxItem.id, "reply-2"); + assert.equal(inboxItem.preview, "First unread reply"); + assert.equal(inboxItem.latestActivityAt, 3); + assert.equal(inboxItem.unreadCount, 2); +}); + +test("thread groups skip an individually read reply when choosing the resume point", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "reply-1", + createdAt: 1, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "root-event", "", "reply"], + ], + }), + item({ + id: "reply-2", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-1", "", "reply"], + ], + }), + ], + }), + getMessageReadAt: (messageId) => (messageId === "reply-1" ? 1 : null), + getThreadReadAt: () => null, + }); + + assert.equal(inboxItem.id, "reply-2"); + assert.equal(inboxItem.unreadCount, 1); +}); + +test("thread groups follow per-message unread state when the aggregate thread marker is newer", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "reply-1", + createdAt: 1, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "root-event", "", "reply"], + ], + }), + item({ + id: "reply-2", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-1", "", "reply"], + ], + }), + ], + }), + getMessageReadAt: (messageId) => (messageId === "reply-1" ? 1 : null), + getThreadReadAt: () => 2, + }); + + assert.equal(inboxItem.id, "reply-2"); + assert.equal(inboxItem.unreadCount, 1); +}); + +test("DMs are grouped by channel and represented by the first unread message", () => { + const inboxItems = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "dm-1", + channelId: DM_CHANNEL_ID, + channelType: undefined, + content: "Already read", + createdAt: 1, + tags: [["h", DM_CHANNEL_ID]], + }), + item({ + id: "dm-2", + channelId: DM_CHANNEL_ID, + channelType: undefined, + content: "First unread", + createdAt: 2, + tags: [["h", DM_CHANNEL_ID]], + }), + item({ + id: "dm-3", + channelId: DM_CHANNEL_ID, + channelType: undefined, + content: "Newest unread", + createdAt: 3, + tags: [["h", DM_CHANNEL_ID]], + }), + ], + }), + getChannelReadAt: (channelId) => (channelId === DM_CHANNEL_ID ? 1 : null), + }); + + assert.equal(inboxItems.length, 1); + assert.equal(inboxItems[0].conversationId, `dm:${DM_CHANNEL_ID}`); + assert.equal(inboxItems[0].id, "dm-2"); + assert.equal(inboxItems[0].preview, "First unread"); + assert.equal(inboxItems[0].latestActivityAt, 3); + assert.equal(inboxItems[0].unreadCount, 2); + assert.deepEqual( + inboxItems[0].groupItems.map((groupItem) => groupItem.id), + ["dm-1", "dm-2", "dm-3"], + ); + assert.equal(findInboxItemByEventId(inboxItems, "dm-3"), inboxItems[0]); +}); + +test("a fully read DM conversation falls back to its latest message", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "dm-1", + channelId: DM_CHANNEL_ID, + content: "Older", + createdAt: 1, + tags: [["h", DM_CHANNEL_ID]], + }), + item({ + id: "dm-2", + channelId: DM_CHANNEL_ID, + content: "Latest", + createdAt: 2, + tags: [["h", DM_CHANNEL_ID]], + }), + ], + }), + getChannelReadAt: () => 2, + }); + + assert.equal(inboxItem.id, "dm-2"); + assert.equal(inboxItem.preview, "Latest"); + assert.equal(inboxItem.unreadCount, 0); +}); + // ── conversationId stability tests ────────────────────────────────────────── test("conversationId is stable when a live reply advances the representative", () => { @@ -272,6 +470,18 @@ test("getInboxConversationId falls back to eventId when no root tag", () => { ); }); +test("getInboxConversationId groups direct messages by channel", () => { + assert.equal( + getInboxConversationId( + [["h", DM_CHANNEL_ID]], + "dm-event", + DM_CHANNEL_ID, + "dm", + ), + `dm:${DM_CHANNEL_ID}`, + ); +}); + test("old event still resolves to its conversation row via groupItems", () => { // Demonstrates that findItemByEventId searching groupItems works: the old // root event id is still present in groupItems even when a newer reply diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index a34ea1b66f9..e34fa0c200f 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -26,7 +26,6 @@ export type InboxFilter = | "mention" | "thread" | "needs_action" - | "activity" | "agent_activity" | "reminders" | "drafts"; @@ -55,6 +54,7 @@ export type InboxItem = { senderLabel: string; subject: string; timestampLabel: string; + unreadCount: number; }; export type InboxTypeLabel = { @@ -238,6 +238,29 @@ export function isThreadActivityItem(item: FeedItem) { return thread.parentId !== null && !isBroadcastReply(item.tags); } +function isThreadReplyItem(item: FeedItem) { + const thread = getThreadReference(item.tags); + return thread.parentId !== null && !isBroadcastReply(item.tags); +} + +function uniqueItemsById(items: readonly FeedItem[]) { + const seen = new Set(); + return items.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); +} + +function isItemUnread( + item: FeedItem, + readAt: number | null, + getMessageReadAt?: (messageId: string) => number | null, +) { + const messageReadAt = getMessageReadAt?.(item.id) ?? null; + return item.createdAt > Math.max(readAt ?? 0, messageReadAt ?? 0); +} + function activityHeadline(item: FeedItem) { return feedHeadline(item); } @@ -338,18 +361,30 @@ function categoryPriority(category: FeedItemCategory) { } } -function getInboxThreadKey(item: FeedItem) { +function getInboxThreadKey( + item: FeedItem, + channelById: ReadonlyMap, +) { const projectReference = getProjectInboxReference(item); if (projectReference) { return `project:${projectReference.repoAddress}:${projectReference.rootId}`; } - const thread = getThreadReference(item.tags); - return thread.rootId ?? thread.parentId ?? item.id; + const channelType = resolveItemChannel(item, channelById).type; + return getInboxConversationId( + item.tags, + item.id, + item.channelId, + channelType, + item.kind, + ); } -function getStableConversationId(item: FeedItem) { - return getInboxItemConversationId(item); +function getStableConversationId( + item: FeedItem, + channelById: ReadonlyMap, +) { + return getInboxThreadKey(item, channelById); } /** @@ -361,6 +396,8 @@ function getStableConversationId(item: FeedItem) { export function getInboxConversationId( tags: string[][], eventId: string, + channelId?: string | null, + channelType?: string, kind?: number, ): string { if (kind !== undefined) { @@ -374,13 +411,37 @@ export function getInboxConversationId( } } + if (channelType === "dm" && channelId) { + return `dm:${channelId}`; + } + const thread = getThreadReference(tags); return thread.rootId ?? thread.parentId ?? eventId; } /** Returns the stable conversation identity for a complete Inbox feed item. */ export function getInboxItemConversationId(item: FeedItem) { - return getInboxConversationId(item.tags, item.id, item.kind); + return getInboxConversationId( + item.tags, + item.id, + item.channelId, + item.channelType, + item.kind, + ); +} + +/** Finds the Inbox row containing an event, including grouped events. */ +export function findInboxItemByEventId( + items: readonly InboxItem[], + eventId: string, +): InboxItem | null { + return ( + items.find((item) => item.id === eventId) ?? + items.find((item) => + item.groupItems.some((groupItem) => groupItem.id === eventId), + ) ?? + null + ); } function formatInboxTimestamp(unixSeconds: number) { @@ -450,11 +511,20 @@ export function buildInboxItems({ channels, currentPubkey, feed, + getChannelReadAt, + getMessageReadAt, + getThreadReadAt, profiles, }: { channels?: InboxChannel[]; currentPubkey?: string; feed?: HomeFeedResponse; + getChannelReadAt?: (channelId: string) => number | null; + getMessageReadAt?: (messageId: string) => number | null; + getThreadReadAt?: ( + rootId: string, + channelId?: string | null, + ) => number | null; profiles?: UserProfileLookup; }): InboxItem[] { if (!feed) { @@ -493,7 +563,7 @@ export function buildInboxItems({ >(); for (const item of feedItems) { - const threadKey = getInboxThreadKey(item); + const threadKey = getInboxThreadKey(item, channelById); const group = threadGroups.get(threadKey) ?? { items: [], latestActivityAt: 0, @@ -502,7 +572,7 @@ export function buildInboxItems({ group.items.push(item); group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt); - if (item.id === getStableConversationId(item)) { + if (item.id === getStableConversationId(item, channelById)) { group.rootItem = item; } @@ -514,11 +584,49 @@ export function buildInboxItems({ ([, left], [, right]) => right.latestActivityAt - left.latestActivityAt, ) .map(([, group]) => { - const conversationId = getStableConversationId(group.items[0]); + const conversationId = getStableConversationId( + group.items[0], + channelById, + ); const latestItem = group.items.reduce((latest, current) => current.createdAt > latest.createdAt ? current : latest, ); - const item = latestItem; + const groupChannel = resolveGroupChannel( + latestItem, + group.items, + channelById, + ); + const groupChannelId = group.items.find( + (candidate) => candidate.channelId, + )?.channelId; + const channelReadAt = + groupChannel.type === "dm" && groupChannelId && getChannelReadAt + ? getChannelReadAt(groupChannelId) + : undefined; + const uniqueGroupItems = uniqueItemsById(group.items); + const threadReplyItems = uniqueGroupItems.filter(isThreadReplyItem); + const threadReadAt = + groupChannel.type !== "dm" && + threadReplyItems.length > 0 && + getThreadReadAt + ? getThreadReadAt(conversationId, groupChannelId) + : undefined; + const unreadItems = ( + channelReadAt !== undefined + ? uniqueGroupItems.filter((candidate) => + isItemUnread(candidate, channelReadAt), + ) + : threadReplyItems.length > 0 && getMessageReadAt + ? threadReplyItems.filter((candidate) => + isItemUnread(candidate, null, getMessageReadAt), + ) + : threadReadAt !== undefined + ? threadReplyItems.filter((candidate) => + isItemUnread(candidate, threadReadAt), + ) + : [] + ).sort((left, right) => left.createdAt - right.createdAt); + const item = unreadItems[0] ?? latestItem; const categories = [ ...new Set(group.items.map((groupItem) => groupItem.category)), ].sort((left, right) => categoryPriority(left) - categoryPriority(right)); @@ -534,7 +642,6 @@ export function buildInboxItems({ item.tags, profiles, ); - const groupChannel = resolveGroupChannel(item, group.items, channelById); const channelLabel = groupChannel.name; const displayItem: FeedItem = { ...item, @@ -561,6 +668,7 @@ export function buildInboxItems({ senderLabel, subject, timestampLabel: formatInboxTimestamp(group.latestActivityAt), + unreadCount: unreadItems.length, }; }); } diff --git a/desktop/src/features/home/lib/inboxListRows.test.mjs b/desktop/src/features/home/lib/inboxListRows.test.mjs new file mode 100644 index 00000000000..b0a093de1b6 --- /dev/null +++ b/desktop/src/features/home/lib/inboxListRows.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInboxListRows } from "./inboxListRows.ts"; + +function inboxItem( + id, + latestActivityAt, + conversationId = `conversation:${id}`, +) { + return { + conversationId, + groupItems: [], + id, + item: { id }, + latestActivityAt, + }; +} + +function reminder( + id, + createdAt, + status = "pending", + { eventId, notBefore } = {}, +) { + return { + id, + createdAt, + notBefore, + content: { + status, + target: eventId ? { eventId } : undefined, + }, + }; +} + +test("Inbox All combines rows in latest-first order", () => { + const rows = buildInboxListRows({ + items: [inboxItem("message", 1_753_099_300)], + reminders: [reminder("reminder", 1_753_099_100)], + }); + + assert.deepEqual( + rows.map((row) => row.kind), + ["inbox", "reminder"], + ); +}); + +test("Inbox All excludes completed reminders", () => { + const rows = buildInboxListRows({ + items: [], + reminders: [reminder("done", 1_753_099_100, "done")], + }); + + assert.deepEqual(rows, []); +}); + +test("Inbox conversation keys stay stable when the representative changes", () => { + const first = buildInboxListRows({ + items: [inboxItem("reply-1", 1, "thread-root")], + reminders: [], + }); + const second = buildInboxListRows({ + items: [inboxItem("reply-2", 2, "thread-root")], + reminders: [], + }); + + assert.equal(first[0].key, "inbox:thread-root"); + assert.equal(second[0].key, first[0].key); +}); + +test("due reminder enriches its existing conversation instead of duplicating it", () => { + const item = inboxItem("message", 100); + item.groupItems = [{ id: "reminded-reply" }]; + const rows = buildInboxListRows({ + items: [item], + reminders: [ + reminder("reminder", 50, "pending", { + eventId: "reminded-reply", + notBefore: 200, + }), + ], + }); + + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, "inbox"); + assert.equal(rows[0].dueReminder?.id, "reminder"); + assert.equal(rows[0].sortAt, 200); +}); + +test("due reminder without a represented conversation sorts at trigger time", () => { + const rows = buildInboxListRows({ + items: [inboxItem("newer-than-creation", 150)], + reminders: [ + reminder("reminder", 50, "pending", { + eventId: "not-in-feed", + notBefore: 200, + }), + ], + }); + + assert.deepEqual( + rows.map((row) => row.kind), + ["reminder", "inbox"], + ); +}); diff --git a/desktop/src/features/home/lib/inboxListRows.ts b/desktop/src/features/home/lib/inboxListRows.ts new file mode 100644 index 00000000000..70311a0d13c --- /dev/null +++ b/desktop/src/features/home/lib/inboxListRows.ts @@ -0,0 +1,82 @@ +import type { InboxItem } from "@/features/home/lib/inbox"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; + +export type InboxListRow = + | { + key: string; + kind: "inbox"; + item: InboxItem; + dueReminder?: Reminder; + sortAt: number; + } + | { + key: string; + kind: "reminder"; + reminder: Reminder; + sortAt: number; + }; + +export function buildInboxListRows({ + items, + reminders, +}: { + items: readonly InboxItem[]; + reminders: readonly Reminder[]; +}): InboxListRow[] { + const consumedReminderIds = new Set(); + const inboxRows = items.map((item): InboxListRow => { + const eventIds = new Set([ + item.id, + item.item.id, + ...item.groupItems.map((groupItem) => groupItem.id), + ]); + const matchingReminders = reminders + .filter( + (reminder) => + reminder.content.status === "pending" && + Boolean( + reminder.content.target?.eventId && + eventIds.has(reminder.content.target.eventId), + ), + ) + .sort( + (left, right) => + (right.notBefore ?? right.createdAt) - + (left.notBefore ?? left.createdAt), + ); + const dueReminder = matchingReminders[0]; + + for (const reminder of matchingReminders) { + consumedReminderIds.add(reminder.id); + } + + return { + key: `inbox:${item.conversationId}`, + kind: "inbox", + item, + dueReminder, + sortAt: Math.max( + item.latestActivityAt, + dueReminder?.notBefore ?? dueReminder?.createdAt ?? 0, + ), + }; + }); + + return [ + ...inboxRows, + ...reminders + .filter( + (reminder) => + reminder.content.status === "pending" && + !consumedReminderIds.has(reminder.id), + ) + .map( + (reminder): InboxListRow => ({ + key: `reminder:${reminder.id}`, + kind: "reminder", + reminder, + sortAt: reminder.notBefore ?? reminder.createdAt, + }), + ), + ].sort((left, right) => right.sortAt - left.sortAt); +} diff --git a/desktop/src/features/home/lib/inboxSelection.test.mjs b/desktop/src/features/home/lib/inboxSelection.test.mjs new file mode 100644 index 00000000000..84088f743ae --- /dev/null +++ b/desktop/src/features/home/lib/inboxSelection.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveInboxFilterSelection } from "./inboxSelection.ts"; + +const items = [ + { conversationId: "first-conversation", id: "first-event" }, + { conversationId: "second-conversation", id: "second-event" }, +]; + +test("filter selection preserves a conversation that remains visible", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: false, + items, + selectedConversationId: "second-conversation", + }), + { autoSelectedEventId: null, preserveSelection: true }, + ); +}); + +test("wide filter selection immediately selects the first valid row", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: false, + items, + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: "first-event", preserveSelection: false }, + ); +}); + +test("narrow filter selection returns to the list when selection is invalid", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: true, + items, + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: null, preserveSelection: false }, + ); +}); + +test("empty filter selection clears detail at every width", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: false, + items: [], + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: null, preserveSelection: false }, + ); +}); diff --git a/desktop/src/features/home/lib/inboxSelection.ts b/desktop/src/features/home/lib/inboxSelection.ts new file mode 100644 index 00000000000..c7c6217a2fb --- /dev/null +++ b/desktop/src/features/home/lib/inboxSelection.ts @@ -0,0 +1,21 @@ +import type { InboxItem } from "@/features/home/lib/inbox"; + +export function resolveInboxFilterSelection({ + isNarrow, + items, + selectedConversationId, +}: { + isNarrow: boolean; + items: readonly Pick[]; + selectedConversationId: string | null; +}) { + const preserveSelection = + selectedConversationId !== null && + items.some((item) => item.conversationId === selectedConversationId); + + return { + autoSelectedEventId: + preserveSelection || isNarrow ? null : (items[0]?.id ?? null), + preserveSelection, + }; +} diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index e76b5512392..2cd6424ab5a 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -4,14 +4,67 @@ import test from "node:test"; import { formatTimelineMessages } from "../../messages/lib/formatTimelineMessages.ts"; import { getConfigNudgeAuthorPubkey } from "../../messages/ui/configNudgeAuthPubkey.ts"; import { + filterInboxItems, getContextMessageDepth, getReactionTargetId, + hasInboxThreadContext, isInboxThreadContextEvent, + matchesInboxAllView, matchesInboxFilter, toInboxContextMessage, toTimelineMessage, } from "./inboxViewHelpers.ts"; +test("Inbox uses the dedicated reminder list instead of feed reminder rows", () => { + const message = { item: { kind: 9 } }; + const reminder = { item: { kind: 40007 } }; + const items = [message, reminder]; + + assert.deepEqual(filterInboxItems(items), [message]); +}); + +test("hasInboxThreadContext finds replies in the grouped row or loaded context", () => { + const root = { tags: [["h", "channel"]] }; + const reply = { + tags: [ + ["h", "channel"], + ["e", "root", "", "reply"], + ], + }; + + assert.equal( + hasInboxThreadContext({ item: root, groupItems: [root, reply] }), + true, + ); + assert.equal( + hasInboxThreadContext({ item: root, groupItems: [root] }, [reply]), + true, + ); +}); + +test("hasInboxThreadContext keeps standalone and broadcast activity unthreaded", () => { + const root = { tags: [["h", "channel"]] }; + const broadcastReply = { + tags: [ + ["h", "channel"], + ["e", "root", "", "reply"], + ["broadcast", "1"], + ], + }; + + assert.equal( + hasInboxThreadContext({ item: root, groupItems: [root] }), + false, + ); + assert.equal( + hasInboxThreadContext({ + item: broadcastReply, + groupItems: [broadcastReply], + }), + false, + ); +}); + // --- matchesInboxFilter --- test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => { @@ -19,6 +72,85 @@ test("matchesInboxFilter returns true for the 'all' filter regardless of categor assert.equal(matchesInboxFilter({ categories: ["mentions"] }, "all"), true); }); +test("Inbox All excludes generic top-level channel traffic", () => { + const owned = new Set(["owned-agent"]); + assert.equal( + matchesInboxAllView( + { + categories: ["activity"], + item: { + channelType: "stream", + pubkey: "human", + tags: [["h", "channel"]], + }, + }, + owned, + ), + false, + ); +}); + +test("Inbox All includes each personally relevant message source", () => { + const owned = new Set(["owned-agent"]); + const cases = [ + { + categories: ["activity"], + item: { channelType: "dm", pubkey: "human", tags: [] }, + }, + { + categories: ["mention"], + item: { channelType: "stream", pubkey: "human", tags: [] }, + }, + { + categories: ["needs_action"], + item: { channelType: "stream", pubkey: "human", tags: [] }, + }, + { + categories: ["activity"], + item: { + channelType: "stream", + pubkey: "human", + tags: [["e", "root", "", "reply"]], + }, + }, + { + categories: ["activity"], + item: { channelType: "stream", pubkey: "OWNED-AGENT", tags: [] }, + }, + { + categories: ["activity"], + item: { + channelType: null, + id: "project-pull-request", + kind: 1618, + pubkey: "human", + tags: [["a", `30617:${"a".repeat(64)}:buzz`]], + }, + }, + ]; + + for (const item of cases) { + assert.equal(matchesInboxAllView(item, owned), true); + } +}); + +test("Inbox All excludes generic updates from agents the user does not own", () => { + assert.equal( + matchesInboxAllView( + { + categories: ["agent_activity"], + item: { + channelType: "stream", + pubkey: "somebody-elses-agent", + tags: [], + }, + }, + new Set(["owned-agent"]), + ), + false, + ); +}); + test("matchesInboxFilter matches when the category is present", () => { assert.equal( matchesInboxFilter({ categories: ["mentions", "activity"] }, "mentions"), @@ -34,6 +166,32 @@ test("matchesInboxFilter is false when the category is absent", () => { assert.equal(matchesInboxFilter({ categories: [] }, "mentions"), false); }); +test("owned-agent filtering uses the representative event author", () => { + const owned = new Set(["owned-agent"]); + assert.equal( + matchesInboxFilter( + { + categories: ["activity"], + item: { pubkey: "OWNED-AGENT" }, + }, + "agent_activity", + owned, + ), + true, + ); + assert.equal( + matchesInboxFilter( + { + categories: ["agent_activity"], + item: { pubkey: "somebody-elses-agent" }, + }, + "agent_activity", + owned, + ), + false, + ); +}); + test("matchesInboxFilter matches thread rows by thread tags", () => { const replyItem = { id: "reply", diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 869dbf18181..42ed8e1a5a6 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -2,6 +2,7 @@ import { formatInboxFullTimestamp, type InboxContextMessage, type InboxFilter, + type InboxItem, } from "@/features/home/lib/inbox"; import { isProjectInboxItem } from "@/features/home/lib/projectInbox"; import { @@ -15,6 +16,8 @@ import type { RelayEvent, UserProfileSummary, } from "@/shared/api/types"; +import { KIND_REMINDER } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; function hasThreadReplyTags(tags: string[][]) { @@ -22,6 +25,19 @@ function hasThreadReplyTags(tags: string[][]) { return thread.parentId !== null && !isBroadcastReply(tags); } +export function filterInboxItems(items: InboxItem[]) { + return items.filter((item) => item.item.kind !== KIND_REMINDER); +} + +export function hasInboxThreadContext( + item: Pick, + contextMessages: readonly Pick[] = [], +) { + return [item.item, ...item.groupItems, ...contextMessages].some((event) => + hasThreadReplyTags(event.tags ?? []), + ); +} + export function matchesInboxFilter( item: { categories: readonly string[]; @@ -29,9 +45,12 @@ export function matchesInboxFilter( item?: FeedItem; }, filter: InboxFilter, + ownedAgentPubkeys?: ReadonlySet, ) { if (filter === "all") { - return true; + return ownedAgentPubkeys + ? matchesInboxAllView(item, ownedAgentPubkeys) + : true; } if (filter === "thread") { @@ -46,9 +65,42 @@ export function matchesInboxFilter( ); } + if (filter === "agent_activity" && ownedAgentPubkeys) { + const representative = item.item ?? item.groupItems?.at(-1); + return representative + ? ownedAgentPubkeys.has(normalizePubkey(representative.pubkey)) + : false; + } + return item.categories.includes(filter); } +export function matchesInboxAllView( + item: { + categories: readonly string[]; + groupItems?: readonly FeedItem[]; + item?: FeedItem; + }, + ownedAgentPubkeys: ReadonlySet, +): boolean { + const representative = item.item ?? item.groupItems?.at(-1); + return ( + representative?.channelType === "dm" || + item.categories.includes("mention") || + [item.item, ...(item.groupItems ?? [])].some((groupItem) => + groupItem ? hasThreadReplyTags(groupItem.tags) : false, + ) || + [item.item, ...(item.groupItems ?? [])].some( + (groupItem) => groupItem && isProjectInboxItem(groupItem), + ) || + item.categories.includes("needs_action") || + Boolean( + representative && + ownedAgentPubkeys.has(normalizePubkey(representative.pubkey)), + ) + ); +} + export function getContextMessageDepth( event: RelayEvent, eventById: ReadonlyMap, diff --git a/desktop/src/features/home/ui/HomePersonalInboxDetail.tsx b/desktop/src/features/home/ui/HomePersonalInboxDetail.tsx new file mode 100644 index 00000000000..044c7689619 --- /dev/null +++ b/desktop/src/features/home/ui/HomePersonalInboxDetail.tsx @@ -0,0 +1,45 @@ +import type { DraftViewItem } from "@/features/messages/ui/DraftsPanel"; +import { DraftDetailPane } from "@/features/messages/ui/DraftDetailPane"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { ReminderDetailPane } from "@/features/reminders/ui/RemindersPanel"; + +type HomePersonalInboxDetailProps = { + currentPubkey?: string; + draftItem: DraftViewItem | null; + mode: "drafts" | "reminders"; + onBack?: () => void; + onDeleteDraft: (draftKey: string) => void; + reminder: Reminder | null; +}; + +export function HomePersonalInboxDetail({ + currentPubkey, + draftItem, + mode, + onBack, + onDeleteDraft, + reminder, +}: HomePersonalInboxDetailProps) { + if (mode === "drafts") { + return ( + + ); + } + + if (mode === "reminders") { + return ( + + ); + } + + return null; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 2bcb231e9d9..0f7c851643c 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -9,21 +9,23 @@ import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { type InboxFilter, - type InboxContextMessage, - type InboxItem, type InboxReply, buildInboxItems, + findInboxItemByEventId, formatInboxFullTimestamp, getInboxItemConversationId, } from "@/features/home/lib/inbox"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; +import { useOwnedAgentPubkeys } from "@/features/home/useOwnedAgentPubkeys"; import { - getReactionTargetId, + filterInboxItems, matchesInboxFilter, - toInboxContextMessage, } from "@/features/home/lib/inboxViewHelpers"; +import { resolveInboxFilterSelection } from "@/features/home/lib/inboxSelection"; import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; -import { useHomeDrafts } from "@/features/home/useHomeDrafts"; +import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelection"; +import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages"; +import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; import { type ProfilePanelTab, @@ -35,32 +37,26 @@ import { profilePanelViewFromSearch, } from "@/features/profile/ui/UserProfilePanelUtils"; import { - INBOX_COLUMN_MIN_WIDTH_PX, INBOX_SINGLE_COLUMN_BREAKPOINT_PX, useResizableInboxListWidth, } from "@/features/home/useResizableInboxListWidth"; +import { getHomePaneLayout } from "@/features/home/lib/homePaneLayout"; +import { getHomeMessageCapabilities } from "@/features/home/lib/homeMessageCapabilities"; import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; -import { DraftDetailPane } from "@/features/messages/ui/DraftDetailPane"; +import { HomePersonalInboxDetail } from "@/features/home/ui/HomePersonalInboxDetail"; import { useChannelMessagesQuery, useToggleReactionMutation, } from "@/features/messages/hooks"; -import { - collectMessageMentionPubkeys, - formatTimelineMessages, -} from "@/features/messages/lib/formatTimelineMessages"; +import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; import { formatTime } from "@/features/messages/lib/dateFormatters"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; -import { - countDueReminders, - useRemindersQuery, -} from "@/features/reminders/hooks"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { HomeFeedResponse } from "@/shared/api/types"; @@ -82,27 +78,6 @@ const INBOX_SEARCH_KEYS = [ "profileView", ] as const; -/** - * Finds the InboxItem whose stable conversation contains the given event ID. - * Checks `item.id` (the current representative/latest event) first, then - * falls back to `item.groupItems` so that a deep-linked or URL-anchored event - * that is no longer the representative still resolves to its row. - */ -function findItemByEventId( - items: readonly InboxItem[], - eventId: string, -): InboxItem | null { - // Fast path: representative event matches (the common case). - const direct = items.find((item) => item.id === eventId); - if (direct) return direct; - // Slow path: event is a non-representative group member (e.g. original - // mention that was later superseded by a newer reply as the representative). - return ( - items.find((item) => item.groupItems.some((gi) => gi.id === eventId)) ?? - null - ); -} - type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; @@ -142,18 +117,29 @@ export function HomeView({ const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; const isMessagesMode = !isReminders && !isDrafts; - const remindersQuery = useRemindersQuery(currentPubkey); - const dueReminderCount = countDueReminders(remindersQuery.data ?? []); + const allowMixedPersonalSelection = filter === "all"; const { - activeCount: activeDraftCount, - deleteDraft: handleDeleteDraft, - items: draftItems, - selectedItem: selectedDraftItem, - selectedKey: selectedDraftKey, - selectDraft: setSelectedDraftKey, - } = useHomeDrafts({ + drafts: { + activeCount: activeDraftCount, + deleteDraft: handleDeleteDraft, + items: draftItems, + selectedItem: selectedDraftItem, + selectedKey: selectedDraftKey, + selectDraft: setSelectedDraftKey, + }, + dueReminderCount, + pendingReminders, + reminders: { + selectedId: selectedReminderId, + selectedItem: selectedReminder, + select: setSelectedReminderId, + }, + } = useHomePersonalInbox({ + allowMixedSelection: allowMixedPersonalSelection, + currentPubkey, isDrafts, isNarrowHomeViewport, + isReminders, viewportWidthPx: homeInboxWidthPx, }); // `?item=` is Messages-mode-only machinery: a reminder never enters the @@ -167,37 +153,23 @@ export function HomeView({ const profilePanelView = profilePanelViewFromSearch( inboxSearchValues.profileView, ); - // Selection state — two-tier design so explicit and automatic selections - // have distinct ownership: - // - // urlSelectedItemId — explicit/user anchor, URL-authoritative. Written - // only by handleUserSelectItem (via applyInboxSearchPatch) and by - // back/forward navigation. Never touched by background data loads. - // - // autoSelectedEventId — default desktop selection when the URL carries no - // explicit anchor. Written only by the auto-selection effect. Never - // triggers a history push. - // - // selectedEventId — the effective anchor used everywhere below: the URL - // anchor when present, otherwise the auto-selected fallback. Derived - // synchronously, no separate state — so there is no mirror-revert race. + // Explicit selection is URL-owned; automatic desktop selection stays local. const [autoSelectedEventId, setAutoSelectedEventId] = React.useState< string | null >(null); + const [unreadBoundary, setUnreadBoundary] = React.useState<{ + conversationId: string; + eventId: string; + } | null>(null); const selectedEventId = urlSelectedItemId ?? autoSelectedEventId; const [managedChannelId, setManagedChannelId] = React.useState( null, ); const { goChannel } = useAppNavigation(); const openDmMutation = useOpenDmMutation(); - // handleUserSelectItem: explicit selection — only patches the URL. - // No local setSelectedEventId call; the URL patch triggers a TanStack Router - // navigation which updates urlSelectedItemId, which becomes selectedEventId - // on the next render. This avoids the mirror-revert race where - // useEffect([urlSelectedItemId]) would fire before navigation commits and - // overwrite the optimistically-set local state with the stale URL null. const handleUserSelectItem = React.useCallback( (itemId: string | null) => { + setAutoSelectedEventId(null); applyInboxSearchPatch({ item: itemId }); }, [applyInboxSearchPatch], @@ -267,6 +239,7 @@ export function HomeView({ getMessageReadAt, feedItemState, markChannelRead, + markMessageRead, markThreadRead, readStateVersion, } = useAppShell(); @@ -290,7 +263,6 @@ export function HomeView({ ? (getThreadReference(activeLatchedItem.tags).parentId ?? activeLatchedItem.id) : null; - const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data; const selectedChannelIdCandidate = React.useMemo(() => { @@ -321,6 +293,13 @@ export function HomeView({ const threadContext = useInboxThreadContext( threadContextFeedItem, channelMessages, + { + fullChannel: + selectedChannel?.channelType === "dm" || + threadContextFeedItem?.channelType === "dm", + hasChannelLoadError: channelMessagesQuery.isError, + isChannelLoading: channelMessagesQuery.isPending, + }, ); const feedProfilePubkeys = React.useMemo( @@ -342,6 +321,11 @@ export function HomeView({ enabled: feedProfilePubkeys.length > 0, }); const feedProfiles = feedProfilesQuery.data?.profiles; + const ownedAgentPubkeys = useOwnedAgentPubkeys( + true, + feedProfiles, + currentPubkey, + ); const feedOwnerPubkeys = React.useMemo( () => [ ...new Set( @@ -356,8 +340,6 @@ export function HomeView({ enabled: feedOwnerPubkeys.length > 0, }); const feedOwnerProfiles = feedOwnerProfilesQuery.data?.profiles; - // Agent set for the inbox list/detail bot badges: the community-scoped - // baseline widened with this surface's profile lookup. const communityAgentPubkeys = useKnownAgentPubkeys(); const inboxAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(communityAgentPubkeys); @@ -370,16 +352,28 @@ export function HomeView({ return pubkeys; }, [feedProfiles, communityAgentPubkeys]); - const inboxItems = React.useMemo( - () => - buildInboxItems({ - channels, - currentPubkey, - feed, - profiles: feedProfiles, - }), - [channels, currentPubkey, feed, feedProfiles], - ); + // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates the stable getChannelReadAt callback + const inboxItems = React.useMemo(() => { + const items = buildInboxItems({ + channels, + currentPubkey, + feed, + getChannelReadAt, + getMessageReadAt, + getThreadReadAt, + profiles: feedProfiles, + }); + return filterInboxItems(items); + }, [ + channels, + currentPubkey, + feed, + feedProfiles, + getChannelReadAt, + getMessageReadAt, + getThreadReadAt, + readStateVersion, + ]); const { effectiveDoneSet, markItemRead, markItemUnread } = useHomeInboxReadState({ items: inboxItems, @@ -390,20 +384,18 @@ export function HomeView({ localDoneSet: doneSet, localUnreadSet: unreadSet, markChannelRead, + markMessageRead, markThreadRead, markDoneLocal: markDone, markUnreadLocal: markUnread, undoDoneLocal: undoDone, undoUnreadLocal: undoUnread, }); - // Resolve the selected row and stable conversation ID from inboxItems - // (unfiltered). We need conversationId before filtering so we can keep the - // selected item visible when unreadOnly is on. The event anchor may point to - // any event in the group (representative or older member), so search both. + // Resolve selection before filtering so unread-only can retain its active row. const selectedItemFromAll = React.useMemo( () => selectedEventId - ? (findItemByEventId(inboxItems, selectedEventId) ?? null) + ? findInboxItemByEventId(inboxItems, selectedEventId) : null, [inboxItems, selectedEventId], ); @@ -421,7 +413,7 @@ export function HomeView({ const filteredItems = React.useMemo(() => { return inboxItems.filter( (item) => - matchesInboxFilter(item, filter) && + matchesInboxFilter(item, filter, ownedAgentPubkeys) && (!unreadOnly || !effectiveDoneSet.has(item.id) || item.conversationId === selectedConversationId), @@ -430,99 +422,45 @@ export function HomeView({ effectiveDoneSet, filter, inboxItems, + ownedAgentPubkeys, selectedConversationId, unreadOnly, ]); - // Prefer the filtered view for the selected item so that filter/unread - // changes can still dismiss it, but fall back to the unfiltered row so a - // live representative-event change (which keeps the conversation in the - // filter) does not make selectedItem go null mid-session. + // A filter change may only retain detail for a conversation that remains + // visible. The filter handler selects the next valid row in the same update, + // so the detail pane never renders a stale conversation between states. const selectedItem = React.useMemo(() => { if (!selectedEventId) return null; - // Primary: find by event anchor in the filtered view. - const fromFiltered = findItemByEventId(filteredItems, selectedEventId); + const fromFiltered = findInboxItemByEventId(filteredItems, selectedEventId); if (fromFiltered) return fromFiltered; - // Secondary: event anchor is in an unfiltered row (e.g., dismissed item). - if (selectedItemFromAll) return selectedItemFromAll; - // Tertiary: anchor has been displaced from all groupItems (e.g., a very old - // event that fell off the feed window). Resolve by conversationId so the - // correct row stays selected and the auto-selection effect doesn't replace - // the anchor with a different conversation. if (selectedConversationId) { return ( filteredItems.find( (item) => item.conversationId === selectedConversationId, - ) ?? - inboxItems.find( - (item) => item.conversationId === selectedConversationId, - ) ?? - null + ) ?? null ); } return null; - }, [ - filteredItems, - inboxItems, - selectedConversationId, - selectedEventId, - selectedItemFromAll, - ]); - const contextMessages = React.useMemo(() => { - if (!selectedItem) { - return []; + }, [filteredItems, selectedConversationId, selectedEventId]); + const unreadBoundaryEventId = React.useMemo(() => { + if (!selectedItem) return null; + if (unreadBoundary?.conversationId === selectedItem.conversationId) { + return unreadBoundary.eventId; } - - const eventById = new Map( - threadContext.events.map((event) => [event.id, event]), - ); - const contextEventIds = new Set(eventById.keys()); - const reactionEvents = [ - ...(channelMessages ?? []), - ...threadContext.reactionEvents, - ].filter((event) => { - if (event.kind !== KIND_REACTION) { - return false; - } - - const targetId = getReactionTargetId(event.tags); - return Boolean(targetId && contextEventIds.has(targetId)); - }); - const currentUserAvatarUrl = currentPubkey - ? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null) - : null; - const timelineMessages = formatTimelineMessages( - [...threadContext.events, ...reactionEvents], - selectedChannel, - currentPubkey, - currentUserAvatarUrl, - feedProfiles, - undefined, - undefined, - undefined, - relaySelfPubkey, - feedOwnerProfiles, - ); - - return timelineMessages.map((message) => - toInboxContextMessage(message, { - eventById, - fallbackAuthorPubkey: selectedItem.item.pubkey, - profiles: feedProfiles, - selectedItemId: selectedEventId ?? selectedItem.id, - }), - ); - }, [ + return effectiveDoneSet.has(selectedItem.id) ? null : selectedItem.id; + }, [effectiveDoneSet, selectedItem, unreadBoundary]); + const contextMessages = useHomeInboxContextMessages({ channelMessages, currentPubkey, - feedProfiles, - feedOwnerProfiles, + events: threadContext.events, + ownerProfiles: feedOwnerProfiles, + profiles: feedProfiles, + reactionEvents: threadContext.reactionEvents, relaySelfPubkey, selectedChannel, selectedEventId, selectedItem, - threadContext.events, - threadContext.reactionEvents, - ]); + }); const selectedItemReplies = React.useMemo(() => { if (!selectedItem) return []; const localReplies = @@ -530,73 +468,20 @@ export function HomeView({ const contextIds = new Set(contextMessages.map((message) => message.id)); return localReplies.filter((reply) => !contextIds.has(reply.id)); }, [contextMessages, localRepliesByItemId, selectedItem]); - React.useEffect(() => { - // Auto-selection is Messages-mode-only: in Reminders mode no FeedItem is - // ever selected, so default-selecting one behind the reminders list would - // be wasted work and could drive narrow-viewport detail off a stale feed - // selection. - if (!isMessagesMode) { - return; - } - - // The URL carries an explicit anchor — auto-selection must not overwrite - // it. Clear any stale auto fallback so it cannot reappear if back later - // returns to a no-item entry. - if (urlSelectedItemId !== null) { - setAutoSelectedEventId(null); - return; - } - - // While the feed is loading (e.g. a reload restoring `?item=` from the - // URL) the selected item simply hasn't arrived yet — don't clobber it. - if (isLoading || !feed) { - return; - } - - if (filteredItems.length === 0) { - setAutoSelectedEventId(null); - return; - } - - // Don't default-select before the width is measured: at width 0 - // isNarrowHomeViewport is false, so narrow Home would cold-load into detail. - if (homeInboxWidthPx === 0) { - return; - } - - // The event anchor is still valid if the conversation it belongs to is - // still present in the filtered list. A live representative-event change - // does NOT invalidate the anchor (the same conversationId is still there). - if ( - selectedConversationId !== null && - filteredItems.some( - (item) => item.conversationId === selectedConversationId, - ) - ) { - return; - } - - // A cold URL anchor is being resolved via getEventById — the user navigated - // to a specific event that is not yet in the inbox list. Do not overwrite - // selectedEventId; wait for cold recovery to commit before auto-selecting. - if (coldResolutionPending) { - return; - } - - setAutoSelectedEventId( - isNarrowHomeViewport ? null : (filteredItems[0]?.id ?? null), - ); - }, [ + useHomeInboxAutoSelection({ coldResolutionPending, - feed, filteredItems, + hasFeed: Boolean(feed), + hasPersonalSelection: + selectedDraftItem !== null || selectedReminder !== null, homeInboxWidthPx, isLoading, isMessagesMode, isNarrowHomeViewport, selectedConversationId, + setAutoSelectedEventId, urlSelectedItemId, - ]); + }); React.useEffect(() => { void selectedConversationId; @@ -604,6 +489,54 @@ export function HomeView({ setIsSendingReply(false); }, [selectedConversationId]); + const handleFilterChange = React.useCallback( + (nextFilter: InboxFilter) => { + const nextItems = inboxItems.filter( + (item) => + matchesInboxFilter(item, nextFilter, ownedAgentPubkeys) && + (!unreadOnly || + !effectiveDoneSet.has(item.id) || + item.conversationId === selectedConversationId), + ); + const selection = resolveInboxFilterSelection({ + isNarrow: isNarrowHomeViewport, + items: nextItems, + selectedConversationId, + }); + + setUnreadBoundary(null); + setSelectedDraftKey(null); + setSelectedReminderId(null); + setFilter(nextFilter); + + if ( + nextFilter === "reminders" || + nextFilter === "drafts" || + selection.preserveSelection + ) { + if (nextFilter === "reminders" || nextFilter === "drafts") { + setAutoSelectedEventId(null); + applyInboxSearchPatch({ item: null }); + } + return; + } + + applyInboxSearchPatch({ item: null }); + setAutoSelectedEventId(selection.autoSelectedEventId); + }, + [ + applyInboxSearchPatch, + effectiveDoneSet, + inboxItems, + isNarrowHomeViewport, + ownedAgentPubkeys, + selectedConversationId, + setSelectedDraftKey, + setSelectedReminderId, + unreadOnly, + ], + ); + if (isLoading && !feed) { return ; } @@ -629,63 +562,43 @@ export function HomeView({ ); } - const canReact = - selectedItem !== null && - selectedItem.item.channelId !== null && - availableChannelIds.has(selectedItem.item.channelId); - const canReply = - canReact && - selectedItem.item.kind !== 45001 && - selectedItem.item.kind !== 45003; - const disabledReplyReason = - canReply || !selectedItem - ? null - : selectedItem.item.channelId - ? availableChannelIds.has(selectedItem.item.channelId) - ? "This item does not support inline replies yet." - : "Open the linked channel to reply." - : "This inbox item does not have a reply target."; - const canDelete = - selectedItem !== null && - currentPubkey?.trim().toLowerCase() === - selectedItem.item.pubkey.trim().toLowerCase(); - const isSinglePanelDetailView = - isMessagesMode && - isNarrowHomeViewport && - selectedEventId !== null && - !isSinglePanelAuxiliaryView; - const isSinglePanelDraftDetailView = - isDrafts && - isNarrowHomeViewport && - selectedDraftItem !== null && - !isSinglePanelAuxiliaryView; - const showListPane = - !isSinglePanelDetailView && - !isSinglePanelDraftDetailView && - !isSinglePanelAuxiliaryView; - const showDetailPane = - !isSinglePanelAuxiliaryView && - ((isMessagesMode && (!isNarrowHomeViewport || isSinglePanelDetailView)) || - (isDrafts && (!isNarrowHomeViewport || isSinglePanelDraftDetailView))); - const auxiliaryPaneWidthPx = isSinglePanelAuxiliaryView - ? homeInboxWidthPx - : threadPanelWidthPx; - const maxEffectiveInboxListWidthPx = - homeInboxWidthPx > 0 - ? Math.max( - INBOX_COLUMN_MIN_WIDTH_PX, - homeInboxWidthPx - - INBOX_COLUMN_MIN_WIDTH_PX - - (hasAuxiliaryPane ? auxiliaryPaneWidthPx : 0), - ) - : undefined; - const effectiveInboxListWidthPx = - homeInboxWidthPx > 0 - ? Math.min( - inboxListWidthPx, - maxEffectiveInboxListWidthPx ?? inboxListWidthPx, - ) - : inboxListWidthPx; + const { canDelete, canReact, canReply, disabledReplyReason } = + getHomeMessageCapabilities( + selectedItem, + currentPubkey, + availableChannelIds, + ); + const detailMode = isDrafts + ? "drafts" + : isReminders + ? "reminders" + : selectedDraftItem + ? "drafts" + : selectedReminder + ? "reminders" + : "messages"; + const { + auxiliaryPaneWidthPx, + effectiveInboxListWidthPx, + isSinglePanelDetailView, + isSinglePanelDraftDetailView, + isSinglePanelReminderDetailView, + showDetailPane, + showListPane, + } = getHomePaneLayout({ + hasAuxiliaryPane, + homeWidthPx: homeInboxWidthPx, + inboxListWidthPx, + isDrafts: detailMode === "drafts", + isMessagesMode: detailMode === "messages", + isNarrow: isNarrowHomeViewport, + isReminders: detailMode === "reminders", + isSinglePanelAuxiliaryView, + selectedDraft: selectedDraftItem !== null, + selectedEvent: selectedEventId !== null, + selectedReminder: selectedReminder !== null, + threadPanelWidthPx, + }); return ( @@ -731,7 +644,7 @@ export function HomeView({ filter={filter} items={filteredItems} onDeleteDraft={handleDeleteDraft} - onFilterChange={setFilter} + onFilterChange={handleFilterChange} onMarkRead={markItemRead} onMarkUnread={markItemUnread} onOpenDirect={(item) => { @@ -758,14 +671,38 @@ export function HomeView({ }); }} onSelect={(itemId) => { + const item = findInboxItemByEventId(inboxItems, itemId); + setUnreadBoundary( + item && !effectiveDoneSet.has(item.id) + ? { + conversationId: item.conversationId, + eventId: item.id, + } + : null, + ); + setSelectedDraftKey(null); + setSelectedReminderId(null); handleUserSelectItem(itemId); markItemRead(itemId); }} - onSelectDraft={setSelectedDraftKey} + onSelectDraft={(draftKey) => { + setUnreadBoundary(null); + setSelectedReminderId(null); + handleUserSelectItem(null); + setSelectedDraftKey(draftKey); + }} + onSelectReminder={(reminderId) => { + setUnreadBoundary(null); + setSelectedDraftKey(null); + handleUserSelectItem(null); + setSelectedReminderId(reminderId); + }} onUnreadOnlyChange={setUnreadOnly} reminderPubkey={currentPubkey} + reminders={pendingReminders} selectedConversationId={selectedConversationId} selectedDraftKey={selectedDraftKey} + selectedReminderId={selectedReminderId} showRightDivider={showListPane && showDetailPane} unreadOnly={unreadOnly} /> @@ -794,7 +731,7 @@ export function HomeView({ - {showDetailPane && isMessagesMode ? ( + {showDetailPane && detailMode === "messages" ? ( { @@ -931,16 +870,20 @@ export function HomeView({ replies={selectedItemReplies} /> ) : null} - {showDetailPane && isDrafts ? ( - setSelectedDraftKey(null) - : undefined + : isSinglePanelReminderDetailView + ? () => setSelectedReminderId(null) + : undefined } - onDelete={handleDeleteDraft} + onDeleteDraft={handleDeleteDraft} + reminder={selectedReminder} /> ) : null} {profilePanelPubkey ? ( diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 5d6d98323f1..9b6ff57e172 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -1,4 +1,12 @@ -import { ArrowLeft, Hash, Mail, MoreHorizontal, Trash2 } from "lucide-react"; +import { + AlertCircle, + ArrowLeft, + ExternalLink, + LoaderCircle, + Mail, + MoreHorizontal, + Trash2, +} from "lucide-react"; import * as React from "react"; import type { @@ -11,6 +19,7 @@ import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; +import { hasInboxThreadContext } from "@/features/home/lib/inboxViewHelpers"; import { type InboxDisplayMessage, InboxMessageRow, @@ -60,6 +69,7 @@ type InboxDetailPaneProps = { isDeletingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; + hasThreadContextLoadError?: boolean; isThreadContextLoading?: boolean; item: InboxItem | null; messages?: InboxContextMessage[]; @@ -75,6 +85,7 @@ type InboxDetailPaneProps = { * representative `item.id`. */ selectedEventId: string | null; + unreadBoundaryEventId?: string | null; /** * The default reply-parent event ID derived from the latched anchor's tags * in HomeView (`parentId ?? anchor.id`). Populated once the anchor is found @@ -95,7 +106,7 @@ type InboxDetailPaneProps = { content: string; mediaTags?: string[][]; mentionPubkeys: string[]; - parentEventId: string; + parentEventId: string | null; }) => Promise; onToggleReaction?: ( message: TimelineMessage, @@ -129,6 +140,7 @@ function InboxMessageDetailPane({ isDeletingMessage = false, isSendingReply = false, isSinglePanelView = false, + hasThreadContextLoadError = false, isThreadContextLoading = false, item, messages = [], @@ -138,6 +150,7 @@ function InboxMessageDetailPane({ contextChannelName = null, currentPubkey, selectedEventId, + unreadBoundaryEventId = null, latchedDefaultParentId = null, onBack, onDelete, @@ -161,6 +174,7 @@ function InboxMessageDetailPane({ // scroll centering) key on this. const conversationId = item?.conversationId ?? null; const selectedChannelId = item?.item.channelId ?? null; + const isDirectMessage = item?.item.channelType === "dm"; // Build the plain, non-virtualized timeline the shared hook anchors against. // Live arrivals rerun its layout compensation without changing the target. @@ -372,7 +386,8 @@ function InboxMessageDetailPane({ // (derived from the selected-event anchor at conversation entry), which does // not change when a live incoming message advances the representative item. const composerParentEventId = - replyTarget?.id ?? capturedDefaultParentId ?? item.id; + replyTarget?.id ?? + (isDirectMessage ? null : (capturedDefaultParentId ?? item.id)); const composerReplyTarget = replyTarget && replyTarget.id !== item.id ? { @@ -388,10 +403,27 @@ function InboxMessageDetailPane({ item.item.channelType === "forum" ? item.item.channelType : null; - const contextLabel = channelContextName ?? formatInboxTypeLabel(item); - const hasChannelContext = Boolean(channelContextName); + const isThreadContext = + !isDirectMessage && hasInboxThreadContext(item, messages); + const contextLabel = isThreadContext + ? isDirectMessage + ? `Thread with ${item.senderLabel}` + : channelContextName + ? `Thread in #${channelContextName}` + : "Thread" + : isDirectMessage + ? `DM with ${item.senderLabel}` + : channelContextName + ? `Message in #${channelContextName}` + : formatInboxTypeLabel(item); const contextChannelId = item.item.channelId; - const contextThreadRootId = getThreadReference(item.item.tags).rootId; + const sourceEventId = selectedEventId ?? item.id; + const contextThreadRootId = isThreadContext ? item.conversationId : null; + const openContextLabel = isThreadContext + ? "Open full thread" + : isDirectMessage + ? "Open conversation" + : "Open in channel"; const handleSelectReplyTarget = (message: InboxDisplayMessage) => { setReplyTargetId((currentReplyTargetId) => @@ -430,34 +462,31 @@ function InboxMessageDetailPane({ ) : null}
{canOpenChannel && contextChannelId ? ( - +

+ +

) : (

- {hasChannelContext ? ( - - ) : null} - + {contextLabel}

@@ -468,6 +497,30 @@ function InboxMessageDetailPane({
+ {canOpenChannel && contextChannelId ? ( + + + + + {openContextLabel} + + ) : null} {channel ? (
+ {isThreadContextLoading && displayMessages.length <= 1 ? ( +
+ + Loading surrounding context... +
+ ) : null} + {hasThreadContextLoadError ? ( +
+ + Some message context could not be loaded. +
+ ) : null} {displayMessages.map((message, index) => { - const isAfterSeparator = index === 1; + const hasUnreadBoundary = message.id === unreadBoundaryEventId; + const isAfterSeparator = index === 1 || hasUnreadBoundary; const previousMessage = displayMessages[index - 1]; const isContinuation = !isAfterSeparator && @@ -528,6 +600,7 @@ function InboxMessageDetailPane({ message={message} onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} + showUnreadBoundary={hasUnreadBoundary} /> ); })} @@ -561,17 +634,25 @@ function InboxMessageDetailPane({ />
setReplyTargetId(null) : undefined @@ -586,7 +667,9 @@ function InboxMessageDetailPane({ } placeholder={ canReply - ? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` + ? isDirectMessage + ? `Message ${item.senderLabel}` + : `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` : (disabledReplyReason ?? "Replies are not available for this item.") } diff --git a/desktop/src/features/home/ui/InboxFilterMenu.tsx b/desktop/src/features/home/ui/InboxFilterMenu.tsx new file mode 100644 index 00000000000..6fcc759731b --- /dev/null +++ b/desktop/src/features/home/ui/InboxFilterMenu.tsx @@ -0,0 +1,107 @@ +import { ChevronDown } from "lucide-react"; + +import type { InboxFilter } from "@/features/home/lib/inbox"; +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +const INBOX_FILTER_OPTIONS: Array<{ + label: string; + value: InboxFilter; +}> = [ + { value: "all", label: "All" }, + { value: "project", label: "Projects" }, + { value: "mention", label: "Mentions" }, + { value: "thread", label: "Threads" }, + { value: "needs_action", label: "Needs action" }, + { value: "agent_activity", label: "Agents" }, + { value: "reminders", label: "Reminders" }, + { value: "drafts", label: "Drafts" }, +]; + +const TRIGGER_CLASS = + "inline-flex h-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 data-[state=open]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 relative -ml-2 w-auto gap-1 px-2 text-sm font-medium text-foreground"; + +type InboxFilterMenuProps = { + activeDraftCount: number; + dueReminderCount: number; + filter: InboxFilter; + onFilterChange: (value: InboxFilter) => void; + reminderCount: number; +}; + +export function InboxFilterMenu({ + activeDraftCount, + dueReminderCount, + filter, + onFilterChange, + reminderCount, +}: InboxFilterMenuProps) { + const activeFilter = INBOX_FILTER_OPTIONS.find( + (option) => option.value === filter, + ); + const statusLabel = + dueReminderCount > 0 + ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` + : activeDraftCount > 0 + ? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}` + : null; + + return ( + + + + + + onFilterChange(value as InboxFilter)} + value={filter} + > + {INBOX_FILTER_OPTIONS.map((option) => ( +
+ {option.value === "reminders" ? ( + + ) : null} + + + {option.label} + + {option.value === "reminders" && reminderCount > 0 ? ( + + {reminderCount} + + ) : option.value === "drafts" && activeDraftCount > 0 ? ( + + {activeDraftCount} + + ) : null} + + + +
+ ))} +
+
+
+ ); +} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 603ebc2beb6..fa214dc730e 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,10 +1,4 @@ -import { - ChevronDown, - Clock, - Ellipsis, - ExternalLink, - MailOpen, -} from "lucide-react"; +import { Bell, Clock, Ellipsis, ExternalLink, MailOpen } from "lucide-react"; import * as React from "react"; import { @@ -13,12 +7,19 @@ import { type InboxItem, type InboxTypeLabel, } from "@/features/home/lib/inbox"; +import { buildInboxListRows } from "@/features/home/lib/inboxListRows"; +import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; import { DraftsPanel, type DraftViewItem, } from "@/features/messages/ui/DraftsPanel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { isDue } from "@/features/reminders/lib/reminderFilters"; +import { + RemindersPanel, + useReminderSources, +} from "@/features/reminders/ui/RemindersPanel"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -34,13 +35,6 @@ import { MENTION_CHIP_BASE_CLASSES, MESSAGE_MARKDOWN_CLASS, } from "@/shared/ui/mentionChip"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Separator } from "@/shared/ui/separator"; import { Switch } from "@/shared/ui/switch"; @@ -48,24 +42,34 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; -const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ - { value: "all", label: "All" }, - { value: "project", label: "Projects" }, - { value: "mention", label: "Mentions" }, - { value: "thread", label: "Threads" }, - { value: "needs_action", label: "Needs Action" }, - { value: "activity", label: "Activity" }, - { value: "agent_activity", label: "Agents" }, - { value: "reminders", label: "Reminders" }, - { value: "drafts", label: "Drafts" }, -]; +const INBOX_EMPTY_STATE_TITLES: Record = { + all: "No activity yet", + project: "No project work found", + mention: "No mentions found", + thread: "No threads found", + needs_action: "Nothing needs action", + agent_activity: "No agent updates found", + reminders: "No reminders", + drafts: "No drafts", +}; + +const INBOX_UNREAD_EMPTY_STATE_TITLES: Record = { + all: "No unread activity", + project: "No unread project work", + mention: "No unread mentions", + thread: "No unread threads", + needs_action: "No unread items needing action", + agent_activity: "No unread agent updates", + reminders: "No unread reminders", + drafts: "No unread drafts", +}; const INBOX_HEADER_ICON_BUTTON_CLASS = "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 data-[state=open]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"; const INBOX_PANE_RIGHT_DIVIDER_CLASS = "after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-40 after:w-px after:bg-border/35 after:content-['']"; -function ActivityLabel({ +function InboxLabel({ isDone, isActionRequired, label, @@ -102,6 +106,71 @@ function ActivityLabel({ ); } +function formatReminderStatus(notBefore: number | undefined) { + if (notBefore === undefined) return "Pending"; + const secondsUntil = notBefore - Math.floor(Date.now() / 1_000); + if (secondsUntil <= 0) return "Reminder due"; + if (secondsUntil < 60) return "Reminder in less than a minute"; + if (secondsUntil < 3_600) { + return `Reminder in ${Math.floor(secondsUntil / 60)}m`; + } + if (secondsUntil < 86_400) { + return `Reminder in ${Math.floor(secondsUntil / 3_600)}h`; + } + return `Reminder in ${Math.floor(secondsUntil / 86_400)}d`; +} + +function PersonalItemRow({ + id, + location, + onClick, + preview, + selected, + status, +}: { + id: string; + location: InboxTypeLabel | null; + onClick: () => void; + preview: string; + selected: boolean; + status: string; +}) { + return ( + + ); +} + type InboxListPaneProps = { activeReminderEventIds?: ReadonlySet; agentPubkeys?: ReadonlySet; @@ -118,12 +187,15 @@ type InboxListPaneProps = { onRemindLater: (item: InboxItem) => void; onSelect: (itemId: string) => void; onSelectDraft: (draftKey: string) => void; + onSelectReminder: (reminderId: string) => void; onUnreadOnlyChange: (checked: boolean) => void; selectedConversationId: string | null; selectedDraftKey: string | null; showRightDivider?: boolean; dueReminderCount: number; reminderPubkey?: string; + reminders: readonly Reminder[]; + selectedReminderId: string | null; unreadOnly: boolean; }; @@ -143,24 +215,41 @@ export function InboxListPane({ onRemindLater, onSelect, onSelectDraft, + onSelectReminder, onUnreadOnlyChange, selectedConversationId, selectedDraftKey, showRightDivider = false, dueReminderCount, reminderPubkey, + reminders, + selectedReminderId, unreadOnly, }: InboxListPaneProps) { - const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; - const inboxStatusLabel = - dueReminderCount > 0 - ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` - : activeDraftCount > 0 - ? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}` - : null; + const isMixedInboxView = filter === "all"; const scrollRef = React.useRef(null); + const inboxRows = React.useMemo( + () => + buildInboxListRows({ + items, + reminders: unreadOnly + ? [] + : reminders.filter((reminder) => + isDue(reminder, Math.floor(Date.now() / 1_000)), + ), + }), + [items, reminders, unreadOnly], + ); + const visibleInboxRows = React.useMemo( + () => + isMixedInboxView + ? inboxRows + : inboxRows.filter((row) => row.kind === "inbox"), + [inboxRows, isMixedInboxView], + ); + const reminderSources = useReminderSources(reminders); const unreadVisibleItemCount = React.useMemo( () => items.reduce((count, item) => count + (doneSet.has(item.id) ? 0 : 1), 0), @@ -174,10 +263,14 @@ export function InboxListPane({ } }, [doneSet, items, onMarkRead]); - const renderItem = (item: InboxItem) => { + const renderItem = (item: InboxItem, dueReminder?: Reminder) => { const isSelected = item.conversationId === selectedConversationId; const isDone = doneSet.has(item.id); - const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false; + const hasActiveReminder = + dueReminder !== undefined || + [item.id, ...item.groupItems.map((groupItem) => groupItem.id)].some( + (eventId) => activeReminderEventIds?.has(eventId) ?? false, + ); const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = getInboxTypeLabel(item); const isSenderAgent = @@ -283,14 +376,28 @@ export function InboxListPane({ className="h-1.5 w-1.5 rounded-full bg-primary" /> ) : null} + {item.unreadCount > 1 ? ( + + {item.unreadCount} unread + + ) : null} {item.timestampLabel}
- + {dueReminder ? ( +
+ + Reminder due +
+ ) : null}
- Show unread + Show unread only
- - - - - - - onFilterChange(value as InboxFilter) - } - value={filter} - > - {FILTER_OPTIONS.map((option) => ( - - - {option.label} - {option.value === "reminders" && - dueReminderCount > 0 ? ( - - {dueReminderCount} - - ) : option.value === "drafts" && - activeDraftCount > 0 ? ( - - {activeDraftCount} - - ) : null} - - - ))} - - - +
@@ -533,7 +582,12 @@ export function InboxListPane({ data-testid="home-inbox-reminders" > {reminderPubkey ? ( - + ) : null}
) : isDrafts ? ( @@ -554,27 +608,62 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > - {items.length === 0 ? ( + {visibleInboxRows.length > 0 ? ( + row.key} + items={visibleInboxRows} + renderItem={(row) => { + if (row.kind === "inbox") { + return renderItem(row.item, row.dueReminder); + } + + const source = reminderSources.get(row.reminder.id); + return ( + { + onSelectReminder(row.reminder.id); + }} + preview={ + row.reminder.content.target?.preview || + row.reminder.content.note || + "Reminder" + } + selected={selectedReminderId === row.reminder.id} + status={formatReminderStatus(row.reminder.notBefore)} + /> + ); + }} + scrollRef={scrollRef} + /> + ) : (

- {unreadOnly ? "No unread messages" : "No messages found"} + {unreadOnly + ? INBOX_UNREAD_EMPTY_STATE_TITLES[filter] + : INBOX_EMPTY_STATE_TITLES[filter]}

{unreadOnly - ? "Turn off the unread filter to see read messages." - : "Switch back to all mail to see more messages."} + ? "Turn off Show unread only to see read activity." + : filter === "all" + ? "New activity will appear here." + : "Switch back to All to see other activity."}

- ) : ( - item.id} - items={items} - renderItem={renderItem} - scrollRef={scrollRef} - /> )}
)} diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index aaad2bcbd91..04deafb3ffd 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -9,6 +9,7 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; +import { UnreadDivider } from "@/features/messages/ui/UnreadDivider"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; @@ -36,6 +37,7 @@ type InboxMessageRowProps = { emoji: string, remove: boolean, ) => Promise; + showUnreadBoundary?: boolean; }; export function InboxMessageRow({ @@ -48,6 +50,7 @@ export function InboxMessageRow({ message, onSelectReplyTarget, onToggleReaction, + showUnreadBoundary = false, }: InboxMessageRowProps) { const timelineMessage = React.useMemo( () => toTimelineMessage(message), @@ -89,6 +92,7 @@ export function InboxMessageRow({ return (
+ {showUnreadBoundary ? : null} {message.isSelected ? (
+ ); +} diff --git a/desktop/src/features/search/hooks.ts b/desktop/src/features/search/hooks.ts index 038c6511f6a..a2db8deeb94 100644 --- a/desktop/src/features/search/hooks.ts +++ b/desktop/src/features/search/hooks.ts @@ -6,23 +6,45 @@ export function useSearchMessagesQuery( query: string, options?: { channelId?: string; + authors?: string[]; + since?: number | null; + until?: number | null; enabled?: boolean; limit?: number; + unresolvedOperator?: boolean; }, ) { const trimmedQuery = query.trim(); const enabled = options?.enabled ?? true; const limit = options?.limit ?? 12; const channelId = options?.channelId; + const authors = options?.authors; + const since = options?.since ?? null; + const until = options?.until ?? null; + const unresolvedOperator = options?.unresolvedOperator ?? false; return useQuery({ - queryKey: ["search-messages", trimmedQuery, limit, channelId ?? null], + queryKey: [ + "search-messages", + trimmedQuery, + limit, + channelId ?? null, + authors ?? null, + since, + until, + unresolvedOperator, + ], queryFn: () => searchMessages({ q: trimmedQuery, limit, channelId, + authors, + since: since ?? undefined, + until: until ?? undefined, }), + // Call sites own the "when to search" floor (FTS length / unresolved + // operators). Keep a single threshold here so it cannot drift. enabled: enabled && trimmedQuery.length >= 2, staleTime: 30_000, gcTime: 5 * 60 * 1_000, diff --git a/desktop/src/features/search/lib/parseSearchOperators.test.mjs b/desktop/src/features/search/lib/parseSearchOperators.test.mjs new file mode 100644 index 00000000000..a528073edc2 --- /dev/null +++ b/desktop/src/features/search/lib/parseSearchOperators.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isChannelUuid, + isHexPubkey, + normalizeFromHandle, + normalizeInChannel, + parseSearchOperators, +} from "./parseSearchOperators.ts"; + +test("leaves plain text unchanged", () => { + assert.deepEqual(parseSearchOperators(" deploy "), { + text: "deploy", + from: null, + in: null, + since: null, + until: null, + }); +}); + +test("extracts from / in / after / before and keeps remaining FTS text", () => { + const parsed = parseSearchOperators( + "deploy from:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef in:#general after:2024-01-15 before:2024-02-01 status", + ); + assert.equal(parsed.text, "deploy status"); + assert.equal( + parsed.from, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ); + assert.equal(parsed.in, "#general"); + assert.equal( + parsed.since, + Math.floor(new Date(2024, 0, 15).getTime() / 1000), + ); + assert.equal( + parsed.until, + Math.floor(new Date(2024, 1, 1).getTime() / 1000) - 1, + ); +}); + +test("before: excludes the named day's exact local midnight", () => { + const localMidnight = Math.floor(new Date(2024, 1, 1).getTime() / 1000); + const parsed = parseSearchOperators("deploy before:2024-02-01"); + + assert.equal(parsed.until, localMidnight - 1); + // The relay keeps events where `created_at <= until`, so a message stamped + // exactly at midnight must fail that predicate. + assert.equal(localMidnight <= parsed.until, false); +}); + +test("keeps invalid date operators in the FTS text", () => { + const parsed = parseSearchOperators("notes after:yesterday before:soon"); + assert.equal(parsed.text, "notes after:yesterday before:soon"); + assert.equal(parsed.since, null); + assert.equal(parsed.until, null); +}); + +test("later operators of the same kind win", () => { + const parsed = parseSearchOperators( + "from:@alice from:@bob in:one in:two after:2024-01-01 after:2024-03-01", + ); + assert.equal(parsed.from, "@bob"); + assert.equal(parsed.in, "two"); + assert.equal(parsed.since, Math.floor(new Date(2024, 2, 1).getTime() / 1000)); + assert.equal(parsed.text, ""); +}); + +test("rejects impossible calendar dates", () => { + const parsed = parseSearchOperators("x after:2024-02-30"); + assert.equal(parsed.since, null); + assert.equal(parsed.text, "x after:2024-02-30"); +}); + +test("does not treat hyphen or slash adjacent tokens as operators", () => { + assert.deepEqual(parseSearchOperators("built-in:react hooks"), { + text: "built-in:react hooks", + from: null, + in: null, + since: null, + until: null, + }); + assert.deepEqual(parseSearchOperators("sign-in:flow broken"), { + text: "sign-in:flow broken", + from: null, + in: null, + since: null, + until: null, + }); + assert.deepEqual(parseSearchOperators("https://x.com/in:foo"), { + text: "https://x.com/in:foo", + from: null, + in: null, + since: null, + until: null, + }); +}); + +test("strips trailing punctuation from operator values", () => { + const parsed = parseSearchOperators("deploy in:general, from:@alice."); + assert.equal(parsed.in, "#general".replace("#", "") || "general"); + assert.equal(parsed.in, "general"); + assert.equal(parsed.from, "@alice"); + assert.equal(parsed.text, "deploy"); +}); + +test("helpers recognize hex pubkeys and channel uuids", () => { + assert.equal( + isHexPubkey( + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ), + true, + ); + assert.equal(isHexPubkey("npub1abc"), false); + assert.equal(isChannelUuid("11111111-1111-1111-1111-111111111111"), true); + assert.equal(normalizeFromHandle("@alice"), "alice"); + assert.equal(normalizeInChannel("#general"), "general"); +}); diff --git a/desktop/src/features/search/lib/parseSearchOperators.ts b/desktop/src/features/search/lib/parseSearchOperators.ts new file mode 100644 index 00000000000..ffa56b28f69 --- /dev/null +++ b/desktop/src/features/search/lib/parseSearchOperators.ts @@ -0,0 +1,168 @@ +/** + * Parse Slack-style search operators out of a free-text query. + * + * Remaining text is the FTS / prefix query. Invalid operator tokens (for example + * `after:yesterday`) are left in the text so they still participate in FTS. + * + * Name resolution for `from:` / `in:` happens at the call site (autocomplete or + * local channel/user lists). This module only splits syntax. + * + * Operators must start at a token boundary (start of string or whitespace). A + * word-boundary `\b` is intentionally avoided: it also matches after `-` / `/`, + * which would turn `built-in:react` or `https://x.com/in:foo` into operators. + */ + +export type ParsedSearchOperators = { + /** FTS / prefix query with operators removed. */ + text: string; + /** Raw `from:` value (pubkey hex, npub, @name, …), if present. */ + from: string | null; + /** Raw `in:` value (channel uuid, #name, …), if present. */ + in: string | null; + /** + * `after:YYYY-MM-DD` → unix seconds at local start of that day (inclusive). + * Messages at or after this timestamp match. + */ + since: number | null; + /** + * `before:YYYY-MM-DD` → one second before local start of that day, because + * NIP-01 `until` is an *inclusive* upper bound. Messages strictly before + * local midnight match, so the named calendar day itself is not included + * (Slack-compatible). + */ + until: number | null; +}; + +/** Token-start only — not `\b`, which fires after hyphens and slashes. */ +const OPERATOR_RE = /(?:^|\s)(from|in|after|before):(\S+)/gi; + +function parseLocalDayStart(value: string): number | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (month < 1 || month > 12 || day < 1 || day > 31) { + return null; + } + + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + + return Math.floor(date.getTime() / 1000); +} + +/** Drop trailing punctuation so `in:general,` still resolves to `general`. */ +function cleanOperatorValue(value: string): string { + return value.replace(/[.,;:!?]+$/g, ""); +} + +/** + * Extract `from:` / `in:` / `after:` / `before:` operators from `raw`. + * + * Later occurrences of the same operator win. Date operators that are not + * `YYYY-MM-DD` stay in the returned `text`. + * + * Multi-word handles (`from:@Will Pfleger`) are a known limitation of part 1: + * only the first whitespace-delimited token is captured. + */ +export function parseSearchOperators(raw: string): ParsedSearchOperators { + let from: string | null = null; + let inValue: string | null = null; + let since: number | null = null; + let until: number | null = null; + + const kept: string[] = []; + let lastIndex = 0; + + for (const match of raw.matchAll(OPERATOR_RE)) { + const index = match.index ?? 0; + kept.push(raw.slice(lastIndex, index)); + lastIndex = index + match[0].length; + + const kind = match[1].toLowerCase(); + const value = cleanOperatorValue(match[2]); + + if (kind === "from") { + from = value; + continue; + } + if (kind === "in") { + inValue = value; + continue; + } + if (kind === "after") { + const parsed = parseLocalDayStart(value); + if (parsed === null) { + kept.push(match[0]); + } else { + since = parsed; + } + continue; + } + if (kind === "before") { + const parsed = parseLocalDayStart(value); + if (parsed === null) { + kept.push(match[0]); + } else { + // NIP-01 `until` is inclusive, so step back one second to keep + // `before:` exclusive of the named day's midnight. + until = parsed - 1; + } + } + } + + kept.push(raw.slice(lastIndex)); + + return { + text: kept.join("").replace(/\s+/g, " ").trim(), + from, + in: inValue, + since, + until, + }; +} + +const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** True when `value` is a 64-char hex pubkey (optional `0x` rejected). */ +export function isHexPubkey(value: string): boolean { + return HEX_PUBKEY_RE.test(value); +} + +/** Strip a leading `@` from a `from:` value. */ +export function normalizeFromHandle(value: string): string { + return value.startsWith("@") ? value.slice(1) : value; +} + +/** Strip a leading `#` from an `in:` value. */ +export function normalizeInChannel(value: string): string { + return value.startsWith("#") ? value.slice(1) : value; +} + +/** True when `value` is a channel UUID. */ +export function isChannelUuid(value: string): boolean { + return UUID_RE.test(value); +} + +/** + * Result of resolving a `from:` / `in:` operator against local candidates. + * + * Distinguishes "no operator" from "operator present but unmatched" so callers + * never silently widen the search when resolution fails. + */ +export type OperatorResolveResult = + | { status: "none" } + | { status: "resolved"; value: T } + | { status: "unresolved" }; diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index aae242fb53c..67c4abb6e9c 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -409,6 +409,7 @@ export function TopbarSearch({ const { channelLookup, debouncedQuery, + isWaitingOnFromResolution, query, resultProfiles, results, @@ -487,7 +488,10 @@ export function TopbarSearch({ const activeResults = isShowingSuggestions ? suggestionResults : groupedSearchResults; - const isSearchLoading = searchQuery.isLoading || userSearchQuery.isLoading; + const isSearchLoading = + isWaitingOnFromResolution || + searchQuery.isLoading || + userSearchQuery.isLoading; const openSearchDialog = React.useCallback(() => { setSelectedMenuIndex(0); diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index e90c5c5f8f7..b31d69a954d 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -11,6 +11,14 @@ import { } from "@/features/profile/hooks"; import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useSearchMessagesQuery } from "@/features/search/hooks"; +import { + isChannelUuid, + isHexPubkey, + normalizeFromHandle, + normalizeInChannel, + parseSearchOperators, + type OperatorResolveResult, +} from "@/features/search/lib/parseSearchOperators"; import type { SearchResult } from "@/features/search/ui/SearchResultItem"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -34,6 +42,56 @@ function dedupeSearchHits(hits: SearchHit[]) { }); } +function resolveChannelIdFromOperator( + raw: string | null, + channels: Channel[], + channelLabels?: Record, +): OperatorResolveResult { + if (!raw) { + return { status: "none" }; + } + const value = normalizeInChannel(raw); + if (!value) { + return { status: "none" }; + } + if (isChannelUuid(value)) { + return { status: "resolved", value }; + } + const needle = value.toLowerCase(); + const match = channels.find((channel) => { + const label = channelLabels?.[channel.id]?.trim() || channel.name; + return ( + channel.name.toLowerCase() === needle || label.toLowerCase() === needle + ); + }); + return match + ? { status: "resolved", value: match.id } + : { status: "unresolved" }; +} + +function resolveAuthorFromOperator( + raw: string | null, + candidates: Array<{ pubkey: string; displayName?: string | null }>, +): OperatorResolveResult { + if (!raw) { + return { status: "none" }; + } + if (isHexPubkey(raw)) { + return { status: "resolved", value: normalizePubkey(raw) }; + } + const handle = normalizeFromHandle(raw).toLowerCase(); + if (!handle) { + return { status: "unresolved" }; + } + const match = candidates.find((candidate) => { + const name = candidate.displayName?.trim().toLowerCase(); + return name === handle || normalizePubkey(candidate.pubkey) === handle; + }); + return match + ? { status: "resolved", value: normalizePubkey(match.pubkey) } + : { status: "unresolved" }; +} + export function useSearchResults({ channelLabels, channels, @@ -55,21 +113,128 @@ export function useSearchResults({ [channels], ); - const searchQuery = useSearchMessagesQuery(debouncedQuery, { - enabled, + const parsedQuery = React.useMemo( + () => parseSearchOperators(debouncedQuery), + [debouncedQuery], + ); + + const channelResolution = React.useMemo( + () => resolveChannelIdFromOperator(parsedQuery.in, channels, channelLabels), + [parsedQuery.in, channels, channelLabels], + ); + + const ftsQuery = parsedQuery.text; + + const hasSearchQuery = + debouncedQuery.trim().length >= MIN_SEARCH_QUERY_LENGTH || + parsedQuery.since !== null || + parsedQuery.until !== null || + parsedQuery.from !== null || + parsedQuery.in !== null; + + const searchBackedQueriesEnabled = enabled && hasSearchQuery; + + const fromHandleForLookup = + parsedQuery.from && !isHexPubkey(parsedQuery.from) + ? normalizeFromHandle(parsedQuery.from) + : ""; + + const managedAgentsQuery = useManagedAgentsQuery({ + enabled: searchBackedQueriesEnabled, + }); + const relayAgentsQuery = useRelayAgentsQuery({ + enabled: searchBackedQueriesEnabled, + }); + // Resolve `from:@name` against people, not only agents. + const fromUserSearchQuery = useUserSearchQuery(fromHandleForLookup, { + enabled: searchBackedQueriesEnabled && fromHandleForLookup.length >= 1, limit, }); + const userSearchQuery = useUserSearchQuery(ftsQuery, { + enabled: searchBackedQueriesEnabled, + limit, + }); + + const authorCandidateSeed = React.useMemo(() => { + const candidates: Array<{ pubkey: string; displayName?: string | null }> = + []; + const seen = new Set(); + const push = (pubkey: string, displayName?: string | null) => { + const key = normalizePubkey(pubkey); + if (seen.has(key)) { + return; + } + seen.add(key); + candidates.push({ pubkey: key, displayName }); + }; + for (const agent of managedAgentsQuery.data ?? []) { + push(agent.pubkey, agent.name); + } + for (const agent of relayAgentsQuery.data ?? []) { + push(agent.pubkey, agent.name); + } + for (const user of fromUserSearchQuery.data ?? []) { + push(user.pubkey, user.displayName); + } + for (const user of userSearchQuery.data ?? []) { + push(user.pubkey, user.displayName); + } + return candidates; + }, [ + managedAgentsQuery.data, + relayAgentsQuery.data, + fromUserSearchQuery.data, + userSearchQuery.data, + ]); - const messageResults = React.useMemo( - () => dedupeSearchHits(searchQuery.data?.hits ?? []), - [searchQuery.data?.hits], + const authorResolution = React.useMemo( + () => resolveAuthorFromOperator(parsedQuery.from, authorCandidateSeed), + [parsedQuery.from, authorCandidateSeed], ); + + const hasUnresolvedOperator = + authorResolution.status === "unresolved" || + channelResolution.status === "unresolved"; + + // While `from:@name` user search is still loading, hold off so we do not + // flash an unresolved empty state before candidates arrive. + const waitingOnFromResolution = + Boolean(fromHandleForLookup) && + fromUserSearchQuery.isLoading && + authorResolution.status === "unresolved"; + + const searchQuery = useSearchMessagesQuery(ftsQuery, { + enabled: + enabled && + !hasUnresolvedOperator && + !waitingOnFromResolution && + ftsQuery.length >= MIN_SEARCH_QUERY_LENGTH, + limit, + channelId: + channelResolution.status === "resolved" + ? channelResolution.value + : undefined, + authors: + authorResolution.status === "resolved" + ? [authorResolution.value] + : undefined, + since: parsedQuery.since, + until: parsedQuery.until, + unresolvedOperator: hasUnresolvedOperator, + }); + + const messageResults = React.useMemo(() => { + if (hasUnresolvedOperator) { + return []; + } + return dedupeSearchHits(searchQuery.data?.hits ?? []); + }, [hasUnresolvedOperator, searchQuery.data?.hits]); const channelResults = React.useMemo(() => { - if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { + if (ftsQuery.length < MIN_SEARCH_QUERY_LENGTH) { return []; } - const normalizedQuery = debouncedQuery.toLowerCase(); + const normalizedQuery = ftsQuery.toLowerCase(); return channels .filter( @@ -100,21 +265,7 @@ export function useSearchResults({ return aDisplayName.localeCompare(bDisplayName); }) .slice(0, 5); - }, [channelLabels, channels, debouncedQuery]); - - const hasSearchQuery = debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH; - const searchBackedQueriesEnabled = enabled && hasSearchQuery; - - const userSearchQuery = useUserSearchQuery(debouncedQuery, { - enabled: searchBackedQueriesEnabled, - limit, - }); - const managedAgentsQuery = useManagedAgentsQuery({ - enabled: searchBackedQueriesEnabled, - }); - const relayAgentsQuery = useRelayAgentsQuery({ - enabled: searchBackedQueriesEnabled, - }); + }, [channelLabels, channels, ftsQuery]); const managedAgentPubkeys = React.useMemo( () => new Set( @@ -145,11 +296,11 @@ export function useSearchResults({ return pubkeys; }, [managedAgentPubkeys, relayAgentsQuery.data]); const userResults = React.useMemo(() => { - if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { + if (ftsQuery.length < MIN_SEARCH_QUERY_LENGTH) { return []; } - const normalizedQuery = debouncedQuery.toLowerCase(); + const normalizedQuery = ftsQuery.toLowerCase(); const candidatesByPubkey = new Map(); const matchesQuery = (candidate: UserSearchResult) => @@ -241,11 +392,11 @@ export function useSearchResults({ candidates: [...candidatesByPubkey.values()], getLabel: formatUserResultName, limit, - query: debouncedQuery, + query: ftsQuery, }); }, [ - debouncedQuery, eligibleAgentPubkeys, + ftsQuery, isArchivedDiscovery, limit, managedAgentPubkeys, @@ -318,6 +469,7 @@ export function useSearchResults({ channelLookup, channelResults, debouncedQuery, + isWaitingOnFromResolution: waitingOnFromResolution, messageResults, query, resultProfiles: resultProfilesQuery.data?.profiles, diff --git a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx deleted file mode 100644 index 268bd863f28..00000000000 --- a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import * as React from "react"; - -import { useManagedAgentsQuery } from "@/features/agents/hooks"; -import { - useManagedAgentRuntimeAction, - useManagedAgentRuntimesQuery, -} from "@/features/agents/managedAgentRuntimeHooks"; -import { - agentCommunityAvailability, - agentCommunityStatusDetail, - managedAgentRuntimeKey, -} from "@/features/agents/managedAgentRuntimeStatus"; -import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; -import { Badge } from "@/shared/ui/badge"; -import { truncatePubkey } from "@/shared/lib/pubkey"; -import { SettingsSectionHeader } from "./SettingsSectionHeader"; - -export function ActiveAgentCommunitiesSettingsCard() { - const agentsQuery = useManagedAgentsQuery(); - const runtimesQuery = useManagedAgentRuntimesQuery(); - const action = useManagedAgentRuntimeAction(); - const [pendingRuntimeKey, setPendingRuntimeKey] = React.useState< - string | null - >(null); - - const agentNames = React.useMemo( - () => - new Map( - (agentsQuery.data ?? []).map((agent) => [ - agent.pubkey.toLowerCase(), - agent.name, - ]), - ), - [agentsQuery.data], - ); - const runtimes = runtimesQuery.data ?? []; - - async function runAction(runtime: ManagedAgentRuntimeStatus) { - setPendingRuntimeKey(managedAgentRuntimeKey(runtime)); - try { - await action.mutateAsync({ - action: - runtime.lifecycle === "starting" || - runtime.lifecycle === "listening" || - runtime.lifecycle === "waking" || - runtime.lifecycle === "ready" - ? "stop" - : runtime.lifecycle === "stopped" - ? "start" - : "restart", - pubkey: runtime.pubkey, - relayUrl: runtime.relayUrl, - }); - } finally { - setPendingRuntimeKey(null); - } - } - - return ( -
- -
- {runtimesQuery.isPending ? ( -

Loading…

- ) : runtimes.length === 0 ? ( -

- No agent community runtimes found. -

- ) : ( - runtimes.map((runtime) => { - const status = agentCommunityAvailability(runtime); - const detail = agentCommunityStatusDetail(runtime); - const runtimeKey = managedAgentRuntimeKey(runtime); - const pending = pendingRuntimeKey === runtimeKey; - return ( -
-
-
-

- {agentNames.get(runtime.pubkey.toLowerCase()) ?? - truncatePubkey(runtime.pubkey)} -

- - {status} - -
-

- {runtime.relayUrl} -

- {detail ? ( -

{detail}

- ) : null} -
- {runtime.localSetup ? ( - - ) : null} -
- ); - }) - )} -
- {action.error instanceof Error ? ( -

{action.error.message}

- ) : null} -
- ); -} diff --git a/desktop/src/features/settings/ui/CustomHarnessForm.tsx b/desktop/src/features/settings/ui/CustomHarnessForm.tsx new file mode 100644 index 00000000000..52e79062650 --- /dev/null +++ b/desktop/src/features/settings/ui/CustomHarnessForm.tsx @@ -0,0 +1,471 @@ +import * as React from "react"; +import { Plus, X } from "lucide-react"; + +import { + useManagedAgentPrereqsQuery, + useSaveCustomHarnessMutation, +} from "@/features/agents/hooks"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + PERSONA_LABEL_OPTIONAL_CLASS, +} from "@/features/agents/ui/agentConfigOptions"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Spinner } from "@/shared/ui/spinner"; + +import { + commaArgError, + type CustomFormValues, + definitionFromFormValues, + idFromLabel, +} from "./harnessFormLogic"; + +// ── Shared empty state ──────────────────────────────────────────────────────── + +export const EMPTY_CUSTOM_FORM: CustomFormValues = { + id: "", + label: "", + command: "", + args: [], + env: [], + installInstructionsUrl: "", + installHint: "", +}; + +// ── Inline command validation ───────────────────────────────────────────────── + +function CommandAvailabilityBadge({ command }: { command: string }) { + const trimmed = command.trim(); + const prereqs = useManagedAgentPrereqsQuery(trimmed, "", { + enabled: trimmed.length > 0, + }); + + if (!trimmed || prereqs.isLoading) return null; + + const available = prereqs.data?.acp.available; + if (available === undefined) return null; + + return ( + + {available ? "Found on PATH" : "Not found on PATH"} + + ); +} + +// ── Shared field chrome (matches the Create agent dialog) ──────────────────── + +function FieldShell({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +const FIELD_INPUT_CLASS = cn( + "h-8 px-0 py-0 leading-6", + PERSONA_FIELD_CONTROL_CLASS, +); + +// ── Args / env editors ──────────────────────────────────────────────────────── + +function ArgsEditor({ + args, + onChange, +}: { + args: string[]; + onChange: (next: string[]) => void; +}) { + function set(index: number, value: string) { + const next = [...args]; + next[index] = value; + onChange(next); + } + + return ( +
+ {args.map((arg, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional arg list +
+ + set(i, e.target.value)} + placeholder={`arg ${i + 1}`} + value={arg} + /> + + +
+ ))} + +
+ ); +} + +function EnvEditor({ + env, + onChange, +}: { + env: Array<{ key: string; value: string }>; + onChange: (next: Array<{ key: string; value: string }>) => void; +}) { + function set(index: number, field: "key" | "value", value: string) { + onChange(env.map((e, i) => (i === index ? { ...e, [field]: value } : e))); + } + + return ( +
+ {env.map((pair, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional env list +
+ + set(i, "key", e.target.value)} + placeholder="KEY" + value={pair.key} + /> + + + set(i, "value", e.target.value)} + placeholder="value" + value={pair.value} + /> + + +
+ ))} + +
+ ); +} + +// ── Custom harness form ─────────────────────────────────────────────────────── + +/** + * Create/edit form for a custom harness. Name and Command are the only + * required fields — that's all a working ACP harness needs. ID (auto-derived + * from name), arguments, env vars, docs URL, and install hint follow inline. + */ +export function CustomHarnessForm({ + initial, + originalId, + onCancel, + onSaved, + chromeless = false, + header, +}: { + initial?: Partial; + /** Id of the harness being edited, if this is an edit (not new). Used to + * delete the old file when the id changes. */ + originalId?: string; + onCancel: () => void; + /** Receives the id the harness was saved under (the form may rewrite it). */ + onSaved: (id: string) => void; + /** Render without the bordered card chrome (for embedding in the catalog + * dialog detail pane). */ + chromeless?: boolean; + /** Optional intro block rendered at the top of the scrollable body + * (chromeless mode) so it scrolls with the fields. */ + header?: React.ReactNode; +}) { + const [form, setForm] = React.useState({ + ...EMPTY_CUSTOM_FORM, + ...initial, + }); + const [error, setError] = React.useState(null); + const save = useSaveCustomHarnessMutation(); + + // Save stays disabled until every required field is satisfied: the three + // required text fields are non-blank and no args/env row is left empty. + const requiredFieldsSatisfied = + form.label.trim().length > 0 && + form.command.trim().length > 0 && + form.id.trim().length > 0 && + form.args.every((arg) => arg.trim().length > 0) && + form.env.every((pair) => pair.key.trim().length > 0); + + function field( + key: keyof Pick< + CustomFormValues, + "id" | "label" | "command" | "installInstructionsUrl" | "installHint" + >, + ) { + return (e: React.ChangeEvent) => { + const value = e.target.value; + setForm((prev) => { + const next = { ...prev, [key]: value }; + // Auto-derive id from label when id is empty or was auto-derived. + if ( + key === "label" && + (!prev.id || prev.id === idFromLabel(prev.label)) + ) { + next.id = idFromLabel(value); + } + return next; + }); + }; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + // Mirror the backend comma-in-args rejection so the user gets an inline + // error naming the offending argument before the round-trip. + const commaError = commaArgError(form.args); + if (commaError) { + setError(commaError); + return; + } + try { + const definition = definitionFromFormValues(form); + await save.mutateAsync({ definition, originalId }); + onSaved(definition.id); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + return ( + void handleSubmit(e)} + > + {chromeless ? null : ( +
+

+ {originalId ? "Edit harness" : "Add custom harness"} +

+ +
+ )} + +
+ {header} + +
+
+ + + + +
+ +
+ + + + +
+
+ +
+
+ + +
+ + + +

+ Any command that speaks ACP over stdio works. +

+
+ +
+

Arguments

+ setForm((p) => ({ ...p, args }))} + /> +
+ +
+

+ Env vars + + (override at spawn time; Buzz-managed vars always win) + +

+ setForm((p) => ({ ...p, env }))} + /> +
+ +
+ + + + +
+ +
+ + + + +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ +
+ +
+ + ); +} diff --git a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx new file mode 100644 index 00000000000..2bc689e9e83 --- /dev/null +++ b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx @@ -0,0 +1,612 @@ +import * as React from "react"; +import { ChevronRight, ExternalLink, Plus, Search } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; + +import { + useAcpRuntimesQuery, + useInstallAcpRuntimeMutation, +} from "@/features/agents/hooks"; +import { + getRuntimeDisplayLabel, + RuntimeIcon, +} from "@/features/onboarding/ui/RuntimeIcon"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { getInstallErrorMessage } from "@/shared/lib/installError"; +import { cn } from "@/shared/lib/cn"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { Spinner } from "@/shared/ui/spinner"; + +import { CustomHarnessForm } from "./CustomHarnessForm"; +import { harnessDescription } from "./harnessCatalogCopy"; +import { + adapterUpdateWarning, + catalogDialogEntries, + catalogPrimaryAction, + entryStatusLabel, + filterCatalogEntries, + groupCatalogEntries, + installLinkLabel, +} from "./harnessCatalogLogic"; + +/** Sentinel list selection for the "+ Custom harness" entry. */ +const CUSTOM_ENTRY_ID = "\u0000custom"; + +/** + * "Add runtimes" — master-detail catalog dialog, modeled on the Agent + * Catalog (PersonaCatalogDialog): searchable left chooser, right detail pane + * with one neutral vendor-sourced sentence, operational setup state, and + * technical details, plus a primary Install / setup-guide CTA pinned in a + * bottom action bar (same position as the custom-harness Save button). + * "+ Custom harness" opens the progressive-disclosure form in the same pane. + */ +export function HarnessCatalogDialog({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const contentRef = React.useRef(null); + const runtimesQuery = useAcpRuntimesQuery(); + const isLoading = runtimesQuery.isLoading; + const entries = React.useMemo( + () => catalogDialogEntries(runtimesQuery.data ?? []), + [runtimesQuery.data], + ); + const [query, setQuery] = React.useState(""); + const filtered = React.useMemo( + () => filterCatalogEntries(entries, query), + [entries, query], + ); + const [selectedId, setSelectedId] = React.useState(null); + const groups = React.useMemo(() => groupCatalogEntries(filtered), [filtered]); + // "Installed" is collapsed by default — the dialog exists to set up new + // runtimes. Searching force-expands it (VS Code-style) so matches are never + // hidden; likewise when there is nothing left to set up. + const [setupOpen, setSetupOpen] = React.useState(true); + const [installedOpen, setInstalledOpen] = React.useState(false); + const isSearching = query.trim().length > 0; + const setupExpanded = setupOpen || isSearching; + const installedExpanded = + installedOpen || isSearching || groups.setup.length === 0; + + // Keep a valid selection: default to the first visible entry; hold on to + // the custom-form selection regardless of the filter. + React.useEffect(() => { + if (!open) return; + setSelectedId((current) => { + if (current === CUSTOM_ENTRY_ID) return current; + if (current && filtered.some((e) => e.id === current)) return current; + return filtered[0]?.id ?? null; + }); + }, [open, filtered]); + + // Reset transient state when the dialog closes. + React.useEffect(() => { + if (!open) { + setQuery(""); + setSelectedId(null); + setSetupOpen(true); + setInstalledOpen(false); + } + }, [open]); + + const selectedEntry = + selectedId === CUSTOM_ENTRY_ID + ? null + : (filtered.find((e) => e.id === selectedId) ?? null); + + return ( + + { + event.preventDefault(); + contentRef.current?.focus(); + }} + ref={contentRef} + scrollAreaClassName="flex min-h-0 overflow-hidden px-0" + scrollAreaTestId="harness-catalog-dialog-body" + tabIndex={-1} + title="Add runtimes" + > +
+ {/* Left: search + chooser list */} +
+
+
+ + setQuery(e.target.value)} + placeholder="Search runtimes…" + value={query} + /> +
+
+
+
+ {isLoading ? ( + + ) : filtered.length === 0 ? ( +

+ No runtimes match. +

+ ) : ( + <> + {groups.setup.length > 0 ? ( + setSetupOpen((v) => !v)} + open={setupExpanded} + testId="harness-catalog-section-setup" + > + {groups.setup.map((entry) => ( + setSelectedId(entry.id)} + /> + ))} + + ) : null} + {groups.installed.length > 0 ? ( + setInstalledOpen((v) => !v)} + open={installedExpanded} + testId="harness-catalog-section-installed" + > + {groups.installed.map((entry) => ( + setSelectedId(entry.id)} + /> + ))} + + ) : null} + + )} +
+
+ +
+
+ + {/* Right: detail pane */} +
+
+ {selectedId === CUSTOM_ENTRY_ID ? ( + onOpenChange(false)} /> + ) : selectedEntry ? ( + + ) : isLoading ? ( +
+ +
+ ) : ( +
+
+

+ Select a runtime on the left, or add a custom one. +

+
+
+ )} +
+
+
+ +
+ ); +} + +/** + * VS Code-style collapsible list section: chevron + uppercase label + + * trailing count badge. The header is a plain toggle — selection lives on + * the rows inside. + */ +function CatalogSection({ + children, + count, + label, + onToggle, + open, + testId, +}: { + children: React.ReactNode; + count: number; + label: string; + onToggle: () => void; + open: boolean; + testId: string; +}) { + return ( +
+ + {open ?
{children}
: null} +
+ ); +} + +/** Pulsing placeholder rows shown while harness discovery is running. */ +function CatalogListSkeleton() { + const widths = ["w-24", "w-32", "w-20", "w-28", "w-24", "w-16"]; + return ( +
+ {widths.map((width, index) => ( +
+ + +
+ ))} +
+ ); +} + +/** Pulsing placeholder mirroring the detail-pane layout while loading. */ +function CatalogDetailSkeleton() { + return ( +
+
+ +
+ + + +
+
+
+ + + +
+ +
+ ); +} + +function CatalogListItem({ + entry, + isCurrent, + onSelect, +}: { + entry: AcpRuntimeCatalogEntry; + isCurrent: boolean; + onSelect: () => void; +}) { + const isReady = entry.availability === "available"; + + return ( + + ); +} + +function CatalogDetail({ entry }: { entry: AcpRuntimeCatalogEntry }) { + const install = useInstallAcpRuntimeMutation(); + const [installError, setInstallError] = React.useState(null); + const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false); + const action = catalogPrimaryAction(entry); + const statusLabel = entryStatusLabel(entry); + const description = harnessDescription(entry.id); + const isReady = entry.availability === "available"; + const docsUrl = entry.installInstructionsUrl.trim(); + + function handleInstall() { + setInstallError(null); + install.mutate(entry.id, { + onSuccess: (result) => { + if (!result.success) { + setInstallError(getInstallErrorMessage(result.steps)); + } + }, + onError: (error) => { + setInstallError( + error instanceof Error ? error.message : "Install failed.", + ); + }, + }); + } + + // Replacing an outdated adapter is a machine-wide mutation — gate it behind + // the same confirmation the runtime row shows (adapterUpdateWarning is the + // shared copy source). Fresh installs stay one-click. + function handlePrimaryClick() { + if (entry.availability === "adapter_outdated") { + setIsUpdateWarningOpen(true); + return; + } + handleInstall(); + } + + // The outline docs button only shows when the entry needs no setup action + // (already ready) — pair it with a hint so the muted styling reads as + // "nothing to do here" rather than a broken primary button. + const isSecondaryCta = action.kind !== "install" && action.kind !== "docs"; + + const primaryCta = + action.kind === "install" ? ( + + ) : docsUrl ? ( + + ) : null; + + return ( +
+
+
+ +
+

+ {getRuntimeDisplayLabel(entry)} +

+ {statusLabel ? ( + // entryStatusLabel is the single availability→label source + // shared with the row chip — when it has something to say + // (setup needed, sign-in needed, config error) it outranks the + // green Ready chip even for available entries. + + {statusLabel} + + ) : isReady ? ( + + Ready + + ) : null} +
+
+ + {description ? ( +

+ {description} +

+ ) : null} + + {entry.installHint ? ( +
+

Setup

+

+ {entry.installHint} +

+
+ ) : null} + + {installError ? ( +

+ {installError} +

+ ) : null} + + +
+ + {primaryCta ? ( +
+ {isSecondaryCta ? ( +

+ Already set up +

+ ) : null} + {primaryCta} +
+ ) : null} + + + + Update {entry.label} adapter? + + {adapterUpdateWarning(entry)} + + + + Cancel + + Update + + + + +
+ ); +} + +function TechnicalDetails({ entry }: { entry: AcpRuntimeCatalogEntry }) { + const rows: Array<{ label: string; value: string }> = [ + { label: "ID", value: entry.id }, + ...(entry.command ? [{ label: "Command", value: entry.command }] : []), + ...(entry.defaultArgs.length > 0 + ? [{ label: "Arguments", value: entry.defaultArgs.join(" ") }] + : []), + ...(entry.underlyingCliPath + ? [{ label: "Underlying CLI", value: entry.underlyingCliPath }] + : []), + ...(entry.binaryPath ? [{ label: "Path", value: entry.binaryPath }] : []), + { + label: "Source", + value: entry.source === "builtin" ? "Built-in" : "Bundled preset", + }, + ]; + + return ( +
+ {rows.map((row) => ( +
+
+ {row.label} +
+
+ {row.value} +
+
+ ))} +
+ ); +} + +function CustomHarnessDetail({ onDone }: { onDone: () => void }) { + return ( +
+ +

+ Custom harness +

+

+ Register any ACP-speaking agent tool as a selectable runtime. +

+
+ } + onCancel={onDone} + onSaved={onDone} + /> +
+ ); +} diff --git a/desktop/src/features/settings/ui/HarnessManagementCard.tsx b/desktop/src/features/settings/ui/HarnessManagementCard.tsx deleted file mode 100644 index 758bf1ebbd5..00000000000 --- a/desktop/src/features/settings/ui/HarnessManagementCard.tsx +++ /dev/null @@ -1,669 +0,0 @@ -import * as React from "react"; -import { ExternalLink, Plus, Terminal, Trash2, X } from "lucide-react"; -import { openUrl } from "@tauri-apps/plugin-opener"; - -import { - useAcpRuntimesQuery, - useDeleteCustomHarnessMutation, - useManagedAgentPrereqsQuery, - useManagedAgentsQuery, - usePersonasQuery, - useSaveCustomHarnessMutation, -} from "@/features/agents/hooks"; -import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Spinner } from "@/shared/ui/spinner"; - -import { - getRuntimeDisplayLabel, - RuntimeIcon, -} from "../../onboarding/ui/RuntimeIcon"; -import { - commaArgError, - type CustomFormValues, - definitionFromFormValues, - formValuesFromCatalogEntry, - idFromLabel, -} from "./harnessFormLogic"; -import { - customEntries as getCustomEntries, - deleteConfirmState, - sortedPresetEntries, -} from "./harnessGalleryLogic"; -import { SettingsSectionHeader } from "./SettingsSectionHeader"; - -// ── Preset card ─────────────────────────────────────────────────────────────── -// -// Preset entries come from the backend catalog (source === "preset"), so we no -// longer maintain a duplicate HARNESS_PRESETS array here. The backend -// PRESET_HARNESSES static drives availability detection and canonical data. - -function PresetCard({ entry }: { entry: AcpRuntimeCatalogEntry }) { - const isDetected = entry.availability === "available"; - - return ( -
-
- -
-

- {getRuntimeDisplayLabel(entry)} -

- {entry.command ? ( -

- {entry.command} -

- ) : null} -
- {isDetected ? ( - - Detected - - ) : null} -
- - {/* Docs link for non-detected presets */} - {!isDetected && entry.installInstructionsUrl ? ( -
- -
- ) : null} -
- ); -} - -// ── Custom harness form ─────────────────────────────────────────────────────── - -const EMPTY_FORM: CustomFormValues = { - id: "", - label: "", - command: "", - args: [], - env: [], - installInstructionsUrl: "", - installHint: "", -}; - -function CommandAvailabilityBadge({ command }: { command: string }) { - const trimmed = command.trim(); - const prereqs = useManagedAgentPrereqsQuery(trimmed, "", { - enabled: trimmed.length > 0, - }); - - if (!trimmed || prereqs.isLoading) return null; - - const available = prereqs.data?.acp.available; - if (available === undefined) return null; - - return ( - - {available ? "Found on PATH" : "Not found on PATH"} - - ); -} - -function ArgsEditor({ - args, - onChange, -}: { - args: string[]; - onChange: (next: string[]) => void; -}) { - function set(index: number, value: string) { - const next = [...args]; - next[index] = value; - onChange(next); - } - - function add() { - onChange([...args, ""]); - } - - function remove(index: number) { - onChange(args.filter((_, i) => i !== index)); - } - - return ( -
- {args.map((arg, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: positional arg list -
- set(i, e.target.value)} - placeholder={`arg ${i + 1}`} - value={arg} - /> - -
- ))} - -
- ); -} - -function EnvEditor({ - env, - onChange, -}: { - env: Array<{ key: string; value: string }>; - onChange: (next: Array<{ key: string; value: string }>) => void; -}) { - function set(index: number, field: "key" | "value", value: string) { - const next = env.map((e, i) => - i === index ? { ...e, [field]: value } : e, - ); - onChange(next); - } - - function add() { - onChange([...env, { key: "", value: "" }]); - } - - function remove(index: number) { - onChange(env.filter((_, i) => i !== index)); - } - - return ( -
- {env.map((pair, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: positional env list -
- set(i, "key", e.target.value)} - placeholder="KEY" - value={pair.key} - /> - set(i, "value", e.target.value)} - placeholder="value" - value={pair.value} - /> - -
- ))} - -
- ); -} - -function CustomHarnessForm({ - initial, - originalId, - onCancel, - onSaved, -}: { - initial?: Partial; - /** Id of the harness being edited, if this is an edit (not new). Used to - * delete the old file when the id changes. */ - originalId?: string; - onCancel: () => void; - onSaved: () => void; -}) { - const [form, setForm] = React.useState({ - ...EMPTY_FORM, - ...initial, - }); - const [error, setError] = React.useState(null); - const save = useSaveCustomHarnessMutation(); - - function field( - key: keyof Pick< - CustomFormValues, - "id" | "label" | "command" | "installInstructionsUrl" | "installHint" - >, - ) { - return (e: React.ChangeEvent) => { - const value = e.target.value; - setForm((prev) => { - const next = { ...prev, [key]: value }; - // Auto-derive id from label when id is empty or was auto-derived. - if ( - key === "label" && - (!prev.id || prev.id === idFromLabel(prev.label)) - ) { - next.id = idFromLabel(value); - } - return next; - }); - }; - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - // Mirror the backend comma-in-args rejection so the user gets an inline - // error naming the offending argument before the round-trip. - const commaError = commaArgError(form.args); - if (commaError) { - setError(commaError); - return; - } - try { - await save.mutateAsync({ - definition: definitionFromFormValues(form), - originalId, - }); - onSaved(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - } - - return ( -
void handleSubmit(e)} - > -
-

- {originalId ? "Edit harness" : "Add custom harness"} -

- -
- -
-
-

Name

- -
-
-

- ID (auto-derived) -

- -
-
- -
-
-

Command

- -
- -
- -
-

Arguments

- setForm((p) => ({ ...p, args }))} - /> -
- -
-

- Env vars{" "} - - (override at spawn time; Buzz-managed vars always win) - -

- setForm((p) => ({ ...p, env }))} - /> -
- -
-

- Docs URL (optional) -

- -
- -
-

- Install hint{" "} - (optional) -

- -
- - {error ? ( -

- {error} -

- ) : null} - -
- - -
-
- ); -} - -// ── Custom harness row ──────────────────────────────────────────────────────── - -function CustomHarnessRow({ entry }: { entry: AcpRuntimeCatalogEntry }) { - const [editing, setEditing] = React.useState(false); - const [confirmingDelete, setConfirmingDelete] = React.useState(false); - const [deleteError, setDeleteError] = React.useState(null); - const del = useDeleteCustomHarnessMutation(); - // Blast-radius data for the delete confirmation — only fetched while the - // confirmation is open, so the row list doesn't poll agents. Confirm stays - // disabled until both queries settle (deleteConfirmState) so a quick click - // can't beat the "N agents will stop launching" warning. - const agentsQuery = useManagedAgentsQuery({ enabled: confirmingDelete }); - const personasQuery = usePersonasQuery({ enabled: confirmingDelete }); - const confirmState = deleteConfirmState( - entry.id, - entry.label, - agentsQuery, - personasQuery, - ); - - if (editing) { - return ( - setEditing(false)} - onSaved={() => setEditing(false)} - /> - ); - } - - return ( -
-
- -
-

{entry.label}

-

- {entry.command ?? entry.id} - {(entry.defaultArgs ?? []).length > 0 - ? ` ${(entry.defaultArgs ?? []).join(" ")}` - : ""} -

-
- {entry.availability === "available" ? ( - - Detected - - ) : ( - - Not installed - - )} -
- - {confirmingDelete ? ( - <> - - - - ) : ( - - )} -
-
- {confirmingDelete ? ( -

- {confirmState.message} -

- ) : null} - {deleteError ? ( -

- {deleteError} -

- ) : null} -
- ); -} - -// ── Main component ──────────────────────────────────────────────────────────── - -export function HarnessManagementCard() { - const runtimesQuery = useAcpRuntimesQuery(); - const catalog = runtimesQuery.data ?? []; - const [showForm, setShowForm] = React.useState(false); - const [presetPrefill, setPresetPrefill] = React.useState< - Partial | undefined - >(undefined); - - // Preset entries come from the backend catalog; sort detected-first then - // alphabetically within each group — via the tested harnessGalleryLogic helper. - const presetEntries = React.useMemo( - () => sortedPresetEntries(catalog), - [catalog], - ); - - const customEntries = getCustomEntries(catalog); - - function handleFormClose() { - setShowForm(false); - setPresetPrefill(undefined); - } - - return ( -
- - - {/* Preset gallery — driven from backend catalog, detected-first */} - {presetEntries.length > 0 ? ( -
-

Presets

-
- {presetEntries.map((entry) => ( - - ))} -
-
- ) : null} - - {/* Custom harnesses list */} - {customEntries.length > 0 ? ( -
-

Custom harnesses

-
- {customEntries.map((entry) => ( - - ))} -
-
- ) : null} - - {/* Add custom / form toggle */} - {showForm ? ( - - ) : ( - - )} - - {runtimesQuery.error instanceof Error ? ( -

- {runtimesQuery.error.message} -

- ) : null} -
- ); -} diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/HarnessRow.tsx similarity index 52% rename from desktop/src/features/settings/ui/DoctorSettingsPanel.tsx rename to desktop/src/features/settings/ui/HarnessRow.tsx index 96127876a82..de6666b8c3b 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/HarnessRow.tsx @@ -1,15 +1,15 @@ import * as React from "react"; -import { EllipsisVertical, ExternalLink, RefreshCw } from "lucide-react"; +import { EllipsisVertical, ExternalLink } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useAcpAuthMethodsQuery, - useAcpRuntimesQuery, useConnectAcpRuntimeMutation, - useGitBashPrerequisiteQuery, + useDeleteCustomHarnessMutation, useInstallAcpRuntimeMutation, + useManagedAgentsQuery, + usePersonasQuery, } from "@/features/agents/hooks"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { RuntimeIcon } from "@/features/onboarding/ui/RuntimeIcon"; import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; @@ -31,66 +31,45 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { SectionHeader } from "@/shared/ui/PageHeader"; import { Spinner } from "@/shared/ui/spinner"; -import { Switch } from "@/shared/ui/switch"; -const RUNTIME_LOGO_URLS: Record = { - "buzz-agent": "/app-icon@2x.png", - claude: "/runtime-icons/claude.png", - codex: "/runtime-icons/codex.png", - goose: "/runtime-icons/goose.svg", -}; - -const RUNTIME_LOGO_SCALE: Record = { - "buzz-agent": "scale-110", - claude: "scale-110", - codex: "scale-110", - goose: "scale-125", -}; - -const RUNTIME_SORT_PRIORITY: Record = { - "buzz-agent": 0, - goose: 1, -}; +import { CustomHarnessForm } from "./CustomHarnessForm"; +import { + adapterUpdateWarning, + entryStatusLabel, + isDownloadPageUrl, +} from "./harnessCatalogLogic"; +import { formValuesFromCatalogEntry } from "./harnessFormLogic"; +import { deleteConfirmState } from "./harnessGalleryLogic"; +/** Link label for the row's install-instructions URL. Distinct from the + * catalog's `installLinkLabel` — rows spell out what the guide covers + * (adapter vs CLI) because the row lacks the catalog's setup context. */ function runtimeInstallGuideLabel(runtime: AcpRuntimeCatalogEntry) { - return runtime.availability === "adapter_missing" || + if ( + runtime.availability === "adapter_missing" || runtime.availability === "adapter_outdated" - ? "Adapter install guide" + ) { + return "Adapter install guide"; + } + return isDownloadPageUrl(runtime.installInstructionsUrl) + ? "Download page" : "CLI setup guide"; } function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { - // Presets deliberately emit an empty avatar_url (no remote or user-supplied - // icon URLs), so route them through RuntimeIcon — the same component the - // preset gallery uses — which owns the PRESET_LOGOS map, the per-logo - // contrast treatments (omp needs a dark chip, grok a light one), and the - // terminal-glyph fallback for logo-less presets like Cursor (brand assets - // not licensed for bundling). Keying on `source` rather than logo presence - // keeps both surfaces identical for every preset. Builtins keep the - // ProfileAvatar path below. - if (runtime.source === "preset") { - return ( - - - - ); - } - - const avatarUrl = RUNTIME_LOGO_URLS[runtime.id] ?? runtime.avatarUrl; - + // Single logo pipeline: RuntimeIcon owns every runtime asset — the + // theme-adaptive RUNTIME_MARKS, the BuzzMark, the bundled bitmap maps + // (RUNTIME_LOGOS / PRESET_LOGOS), and the terminal-glyph fallback. It never + // renders remote or user-supplied avatar URLs (security line), so the row + // and the catalog cannot drift apart. return ( - + + + ); } @@ -99,12 +78,16 @@ function RuntimeOverflowMenu({ connectingMethodId, isConnecting, onConnect, + onDelete, + onEdit, runtime, }: { authMethods: AcpAuthMethod[]; connectingMethodId: string | null; isConnecting: boolean; onConnect: (method: AcpAuthMethod) => void; + onDelete?: () => void; + onEdit?: () => void; runtime: AcpRuntimeCatalogEntry; }) { const hasInstructions = @@ -113,7 +96,11 @@ function RuntimeOverflowMenu({ runtime.authStatus.status === "logged_out" || runtime.authStatus.status === "config_invalid"); const hasActions = - runtime.nodeRequired || hasInstructions || authMethods.length > 0; + runtime.nodeRequired || + hasInstructions || + authMethods.length > 0 || + Boolean(onEdit) || + Boolean(onDelete); if (!hasActions) { return null; @@ -161,6 +148,23 @@ function RuntimeOverflowMenu({ {runtimeInstallGuideLabel(runtime)} ) : null} + {onEdit ? ( + + Edit + + ) : null} + {onDelete ? ( + + Delete + + ) : null} ); @@ -172,6 +176,8 @@ function RuntimeActions({ isConnecting, isInstalling, onConnect, + onDelete, + onEdit, onInstall, runtime, }: { @@ -180,10 +186,17 @@ function RuntimeActions({ isConnecting: boolean; isInstalling: boolean; onConnect: (method: AcpAuthMethod) => void; + onDelete?: () => void; + onEdit?: () => void; onInstall: () => void; runtime: AcpRuntimeCatalogEntry; }) { const isAvailable = runtime.availability === "available"; + // Signed-out rows carry the amber "Sign-in needed" status chip instead of a + // green Ready chip — auth-required is an explicit row-face state, and Ready + // must not claim otherwise. + const isAuthNeeded = + isAvailable && runtime.authStatus.status === "logged_out"; const canInstall = runtime.canAutoInstall && !runtime.nodeRequired; const isWorking = isInstalling || isConnecting; @@ -194,52 +207,62 @@ function RuntimeActions({ connectingMethodId={connectingMethodId} isConnecting={isConnecting} onConnect={onConnect} + onDelete={onDelete} + onEdit={onEdit} runtime={runtime} /> {isWorking ? ( -
+
- ) : ( - { - if (checked) { - onInstall(); - } - }} - /> - )} + ) : isAvailable ? ( + isAuthNeeded ? null : ( // Signed-out rows carry the amber status chip instead; never Install. + + Ready + + ) + ) : canInstall ? ( + // Rows needing multi-step setup render no action here — setup lives in + // the Add-runtimes catalog. Custom rows keep their ••• menu instead. + + ) : null}
); } function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { - const label = - runtime.authStatus.status === "config_invalid" - ? "Config error" - : runtime.availability === "adapter_missing" - ? "Adapter needed" - : runtime.availability === "adapter_outdated" - ? "Update needed" - : runtime.availability === "cli_missing" || - runtime.availability === "not_installed" - ? "CLI needed" - : null; + // Single availability→label source: entryStatusLabel drives this row chip + // AND the catalog detail chip, so the two surfaces cannot drift. That + // includes "Sign-in needed" for installed-but-signed-out runtimes — an + // explicit auth-required state on the row face, not just a ••• menu item. + const label = entryStatusLabel(runtime); if (!label) { return null; } const isConfigError = runtime.authStatus.status === "config_invalid"; + const isAuthNeeded = + !isConfigError && + runtime.availability === "available" && + runtime.authStatus.status === "logged_out"; return ( <> @@ -251,7 +274,9 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { "inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-medium", isConfigError ? "bg-destructive/10 text-destructive" - : "bg-muted text-muted-foreground", + : isAuthNeeded + ? "bg-amber-500/15 text-amber-600 dark:text-amber-400" + : "bg-muted text-muted-foreground", )} data-testid={`doctor-runtime-status-${runtime.id}`} > @@ -261,56 +286,28 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ); } -function RuntimeHeader({ - authMethods, - connectingMethodId, - isConnecting, - isInstalling, - onConnect, - onInstall, - runtime, -}: { - authMethods: AcpAuthMethod[]; - connectingMethodId: string | null; - isConnecting: boolean; - isInstalling: boolean; - onConnect: (method: AcpAuthMethod) => void; - onInstall: () => void; - runtime: AcpRuntimeCatalogEntry; -}) { - return ( -
-
- -
-

{runtime.label}

- -
-
- -
- ); -} - -function RuntimeRow({ +/** + * One row in "Your runtimes". + * + * Carries the full operational surface for a ready (or one-click-ready) + * harness: logo, status chip, auth/overflow menu, install/connect flows, and + * — for custom harnesses — edit and delete with the blast-radius guard. + */ +export function HarnessRow({ resetEpoch, runtime, }: { resetEpoch: number; runtime: AcpRuntimeCatalogEntry; }) { + const isCustom = runtime.source === "custom"; const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState< string | null >(null); const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false); + const [editing, setEditing] = React.useState(false); + const [confirmingDelete, setConfirmingDelete] = React.useState(false); + const [deleteError, setDeleteError] = React.useState(null); // Each row owns its mutation instance so concurrent installs each track // their own isPending / result state independently. const installMutation = useInstallAcpRuntimeMutation(); @@ -328,6 +325,20 @@ function RuntimeRow({ const isInstalling = installMutation.isPending; const installError = installResult?.error ?? null; + const del = useDeleteCustomHarnessMutation(); + // Blast-radius data for the delete confirmation — only fetched while the + // confirmation is open, so the row list doesn't poll agents. Confirm stays + // disabled until both queries settle (deleteConfirmState) so a quick click + // can't beat the "N agents will stop launching" warning. + const agentsQuery = useManagedAgentsQuery({ enabled: confirmingDelete }); + const personasQuery = usePersonasQuery({ enabled: confirmingDelete }); + const confirmState = deleteConfirmState( + runtime.id, + runtime.label, + agentsQuery, + personasQuery, + ); + function handleInstall() { setInstallResult(null); installMutation.mutate(runtime.id, { @@ -374,42 +385,71 @@ function RuntimeRow({ }` : null; + if (editing) { + return ( + setEditing(false)} + onSaved={() => setEditing(false)} + /> + ); + } + return (
- { - setTerminalLaunchMethodId(null); - connectMutation.mutate( - { - runtimeId: runtime.id, - methodId: method.id, - }, - { - onSuccess: (result) => { - if (result.launched && method.type === "terminal") { - setTerminalLaunchMethodId(method.id); - } +
+
+ +
+

{runtime.label}

+ +
+
+ { + setTerminalLaunchMethodId(null); + connectMutation.mutate( + { + runtimeId: runtime.id, + methodId: method.id, + }, + { + onSuccess: (result) => { + if (result.launched && method.type === "terminal") { + setTerminalLaunchMethodId(method.id); + } + }, }, - }, - ); - }} - onInstall={() => { - if (runtime.availability === "adapter_outdated") { - setIsUpdateWarningOpen(true); - return; + ); + }} + onDelete={ + isCustom + ? () => { + setDeleteError(null); + setConfirmingDelete(true); + } + : undefined } - handleInstall(); - }} - runtime={runtime} - /> + onEdit={isCustom ? () => setEditing(true) : undefined} + onInstall={() => { + if (runtime.availability === "adapter_outdated") { + setIsUpdateWarningOpen(true); + return; + } + handleInstall(); + }} + runtime={runtime} + /> +
{runtime.availability !== "available" ? (
) : null} + {confirmingDelete ? ( +
+

+ {confirmState.message} +

+
+ + +
+
+ ) : null} + {deleteError ? ( +

+ {deleteError} +

+ ) : null}
Update {runtime.label} adapter? - This replaces the machine-wide codex-acp adapter. Older Buzz - releases using the legacy adapter may lose community access until - @zed-industries/codex-acp@0.16.0 is restored. + {adapterUpdateWarning(runtime)} @@ -492,152 +581,3 @@ function RuntimeRow({
); } - -function GitBashCard({ - prerequisite, -}: { - prerequisite: NonNullable< - ReturnType["data"] - >; -}) { - return ( -
-
-
-
-

Git Bash

- - - {prerequisite.available ? "Available" : "Action needed"} - -
- {!prerequisite.available ? ( - - ) : null} -
- {!prerequisite.available ? ( -
-

Required for buzz-agent shell tools on Windows.

-

{prerequisite.installHint}

-
- ) : null} -
-
- ); -} - -export function DoctorSettingsPanel() { - const runtimesQuery = useAcpRuntimesQuery(); - const gitBashQuery = useGitBashPrerequisiteQuery(); - const runtimes = React.useMemo( - () => - [...(runtimesQuery.data ?? [])].sort( - (left, right) => - (RUNTIME_SORT_PRIORITY[left.id] ?? Number.MAX_SAFE_INTEGER) - - (RUNTIME_SORT_PRIORITY[right.id] ?? Number.MAX_SAFE_INTEGER), - ), - [runtimesQuery.data], - ); - const isRefreshing = runtimesQuery.isFetching; - // Incremented each time the user clicks "Check again" so RuntimeRow - // useEffect clears stale install results from before the refresh. - const [resetEpoch, setResetEpoch] = React.useState(0); - - return ( -
- { - setResetEpoch((e) => e + 1); - void runtimesQuery.refetch(); - void gitBashQuery.refetch(); - }} - size="sm" - type="button" - variant="outline" - > - - Check again - - } - /> - -
- {gitBashQuery.data ? ( -
-
-

- System prerequisites -

-

- Windows tools required by supported agents. -

-
- -
- ) : null} - -
- {runtimesQuery.isLoading ? ( -
- Checking agent runtimes... -
- ) : runtimes.length > 0 ? ( -
- {runtimes.map((runtime) => ( - - ))} -
- ) : ( -
- No supported agent runtimes found. -
- )} - - {runtimesQuery.error instanceof Error ? ( -

- {runtimesQuery.error.message} -

- ) : null} -
-
-
- ); -} diff --git a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx new file mode 100644 index 00000000000..10ca2a377f1 --- /dev/null +++ b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx @@ -0,0 +1,211 @@ +import * as React from "react"; +import { ExternalLink, Plus, RefreshCw } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; + +import { + useAcpRuntimesQuery, + useGitBashPrerequisiteQuery, +} from "@/features/agents/hooks"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { SectionHeader } from "@/shared/ui/PageHeader"; + +import { HarnessCatalogDialog } from "./HarnessCatalogDialog"; +import { HarnessRow } from "./HarnessRow"; +import { stableRowOrder, yourHarnessEntries } from "./harnessCatalogLogic"; + +function GitBashCard({ + prerequisite, +}: { + prerequisite: NonNullable< + ReturnType["data"] + >; +}) { + return ( +
+
+
+
+

Git Bash

+ + + {prerequisite.available ? "Available" : "Action needed"} + +
+ {!prerequisite.available ? ( + + ) : null} +
+ {!prerequisite.available ? ( +
+

Required for buzz-agent shell tools on Windows.

+

{prerequisite.installHint}

+
+ ) : null} +
+
+ ); +} + +/** + * Consolidated "Agent runtimes" surface for Settings → Agents. + * + * Replaces the old "Agent runtimes" (DoctorSettingsPanel) + "Bring your own + * harness" (HarnessManagementCard) pair with one operational area: + * + * - **Your runtimes** — stable rows for ready (or one-click-ready) runtimes + * and everything the user authored. Row order never changes when a runtime + * installs (stableRowOrder), so the page doesn't jump under the pointer. + * - **Add runtimes** — a master-detail catalog dialog for everything that + * needs multi-step setup, plus the custom-harness form. + */ +export function HarnessesSettingsPanel() { + const runtimesQuery = useAcpRuntimesQuery(); + const gitBashQuery = useGitBashPrerequisiteQuery(); + const [catalogOpen, setCatalogOpen] = React.useState(false); + // Incremented each time the user clicks "Check again" so HarnessRow + // useEffect clears stale install results from before the refresh. + const [resetEpoch, setResetEpoch] = React.useState(0); + + const entries = React.useMemo( + () => yourHarnessEntries(runtimesQuery.data ?? []), + [runtimesQuery.data], + ); + + // Sticky row order: initial sort once, then preserve relative order across + // refetches/toggles so enabling a harness never reorders the list. + const orderRef = React.useRef([]); + const rows = React.useMemo(() => { + orderRef.current = stableRowOrder(orderRef.current, entries); + const byId = new Map(entries.map((e) => [e.id, e])); + return orderRef.current + .map((id) => byId.get(id)) + .filter((e): e is AcpRuntimeCatalogEntry => e !== undefined); + }, [entries]); + + const isRefreshing = runtimesQuery.isFetching; + + return ( +
+ { + setResetEpoch((e) => e + 1); + void runtimesQuery.refetch(); + void gitBashQuery.refetch(); + }} + size="sm" + type="button" + variant="outline" + > + + Check again + + } + /> + +
+ {gitBashQuery.data ? ( +
+
+

+ System prerequisites +

+

+ Windows tools required by supported agents. +

+
+ +
+ ) : null} + +
+ {/* The sub-header only earns its keep when another section (System + prerequisites, Windows-only) shares the page; otherwise it just + restates the page header. */} + {gitBashQuery.data ? ( +
+

+ Your runtimes +

+

+ Ready to use, or one click from installed. +

+
+ ) : null} + + {runtimesQuery.isLoading ? ( +
+ Checking agent runtimes... +
+ ) : rows.length > 0 ? ( +
+ {rows.map((runtime) => ( + + ))} +
+ ) : ( +
+ No agent runtimes ready yet — add one below. +
+ )} + + {runtimesQuery.error instanceof Error ? ( +

+ {runtimesQuery.error.message} +

+ ) : null} + + +
+
+ + +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 1940277774f..e74d1f38371 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -69,8 +69,7 @@ import { withAccentPreviewVars, } from "@/shared/theme/useThemePreviewVars"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; -import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; -import { HarnessManagementCard } from "./HarnessManagementCard"; +import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; @@ -78,7 +77,6 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; -import { ActiveAgentCommunitiesSettingsCard } from "./ActiveAgentCommunitiesSettingsCard"; import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; @@ -815,9 +813,7 @@ export function renderSettingsSection( return (
- - - +
); diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts new file mode 100644 index 00000000000..79c55f01482 --- /dev/null +++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts @@ -0,0 +1,55 @@ +/** + * Curated one-line descriptions for harness catalog entries. + * + * Content policy (agreed in the BYOH UX thread): exactly ONE neutral, + * vendor-sourced category sentence per entry — what kind of tool it is — + * with provenance cited inline. No feature inventories, no marketing + * superlatives, no volatile model/provider claims. Operational setup copy + * (status, hints) stays generated from runtime state, never hand-authored + * here. Research: ~/.buzz/RESEARCH/BYOH_CATALOG_IA.md. + */ + +const HARNESS_DESCRIPTIONS: Record = { + // Built-in runtimes. + "buzz-agent": "Buzz's built-in agent runtime, bundled with the app.", + // Source: https://code.claude.com/docs/en/overview — "Claude Code is an + // agentic coding tool" that lives in the terminal. + claude: "Anthropic's agentic coding tool that runs in the terminal.", + // Source: https://developers.openai.com/codex — "Codex is OpenAI's coding + // agent". + codex: "OpenAI's coding agent, connected through the codex-acp adapter.", + // Source: https://block.github.io/goose/ — "an open source, extensible AI + // agent". + goose: "Block's open-source, extensible AI agent.", + + // Bundled presets — sources per RESEARCH/BYOH_CATALOG_IA.md. + // Source: https://cursor.com/docs/cli/acp + cursor: "Cursor's coding agent, connected to Buzz through its ACP server.", + // Source: https://github.com/can1357/oh-my-pi + omp: "A terminal coding agent with integrated development tools.", + // Source: https://build.x.ai (docs unavailable during research; kept + // deliberately conservative). + grok: "xAI's coding agent, connected to Buzz through its ACP entrypoint.", + // Source: https://github.com/anomalyco/opencode + opencode: "An open-source coding agent.", + // Sources: https://github.com/MoonshotAI/kimi-cli, + // https://moonshotai.github.io/kimi-cli/en/ + kimi: "A terminal coding agent for software development and command-line tasks.", + // Sources: https://ampcode.com, https://ampcode.com/manual + amp: "A coding agent from Sourcegraph.", + // Sources: https://github.com/NousResearch/hermes-agent, + // https://hermes-agent.nousresearch.com/docs/ + hermes: "A general-purpose AI agent from Nous Research.", + // Sources: https://github.com/openclaw/openclaw, + // https://docs.openclaw.ai/start/getting-started + openclaw: "A personal AI assistant that runs on your own devices.", +}; + +/** + * One neutral sentence describing the harness, or null for entries we don't + * curate (customs, unknown ids). Callers must render nothing rather than + * invent copy. + */ +export function harnessDescription(id: string): string | null { + return HARNESS_DESCRIPTIONS[id.trim().toLowerCase()] ?? null; +} diff --git a/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs b/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs new file mode 100644 index 00000000000..28843bdfd27 --- /dev/null +++ b/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs @@ -0,0 +1,400 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + adapterUpdateWarning, + catalogDialogEntries, + catalogPrimaryAction, + entryStatusLabel, + filterCatalogEntries, + groupCatalogEntries, + isYourHarnessEntry, + stableRowOrder, + yourHarnessEntries, +} from "./harnessCatalogLogic.ts"; + +// ── Minimal catalog entry factory ──────────────────────────────────────────── + +function entry(overrides = {}) { + return { + id: "test-id", + label: "Test", + source: "preset", + availability: "not_installed", + avatarUrl: "", + command: null, + binaryPath: null, + defaultArgs: [], + mcpCommand: null, + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + requiresExternalCli: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + ...overrides, + }; +} + +// ── isYourHarnessEntry / yourHarnessEntries ────────────────────────────────── + +describe("isYourHarnessEntry", () => { + it("includes available entries", () => { + assert.equal( + isYourHarnessEntry(entry({ availability: "available" })), + true, + ); + }); + + it("includes one-click installable entries (auto-install, no node gate)", () => { + assert.equal( + isYourHarnessEntry(entry({ canAutoInstall: true, nodeRequired: false })), + true, + ); + }); + + it("excludes auto-installable entries blocked on Node.js", () => { + assert.equal( + isYourHarnessEntry(entry({ canAutoInstall: true, nodeRequired: true })), + false, + ); + }); + + it("excludes presets needing manual setup", () => { + assert.equal( + isYourHarnessEntry( + entry({ availability: "cli_missing", canAutoInstall: false }), + ), + false, + ); + }); + + it("always includes custom entries regardless of readiness", () => { + assert.equal( + isYourHarnessEntry( + entry({ source: "custom", availability: "not_installed" }), + ), + true, + ); + }); +}); + +describe("yourHarnessEntries", () => { + it("filters to owned/ready rows only", () => { + const catalog = [ + entry({ id: "ready", availability: "available" }), + entry({ id: "needs-setup", availability: "cli_missing" }), + entry({ id: "mine", source: "custom" }), + ]; + assert.deepEqual( + yourHarnessEntries(catalog).map((e) => e.id), + ["ready", "mine"], + ); + }); +}); + +// ── catalogDialogEntries ───────────────────────────────────────────────────── + +describe("catalogDialogEntries", () => { + it("excludes custom entries and sorts needs-setup first, alpha within group", () => { + const catalog = [ + entry({ id: "zed", label: "Zed", availability: "available" }), + entry({ id: "mine", label: "Mine", source: "custom" }), + entry({ id: "beta", label: "Beta", availability: "cli_missing" }), + entry({ id: "alpha", label: "Alpha", availability: "not_installed" }), + ]; + assert.deepEqual( + catalogDialogEntries(catalog).map((e) => e.id), + ["alpha", "beta", "zed"], + ); + }); +}); + +// ── groupCatalogEntries ────────────────────────────────────────────────────── + +describe("groupCatalogEntries", () => { + it("splits ready entries into installed, everything else into setup", () => { + const entries = [ + entry({ id: "needs-cli", availability: "cli_missing" }), + entry({ id: "ready", availability: "available" }), + entry({ id: "needs-adapter", availability: "adapter_missing" }), + ]; + const groups = groupCatalogEntries(entries); + assert.deepEqual( + groups.setup.map((e) => e.id), + ["needs-cli", "needs-adapter"], + ); + assert.deepEqual( + groups.installed.map((e) => e.id), + ["ready"], + ); + }); + + it("preserves input order within each group", () => { + const entries = [ + entry({ id: "b", availability: "available" }), + entry({ id: "a", availability: "available" }), + ]; + assert.deepEqual( + groupCatalogEntries(entries).installed.map((e) => e.id), + ["b", "a"], + ); + }); + + it("handles empty input", () => { + assert.deepEqual(groupCatalogEntries([]), { setup: [], installed: [] }); + }); +}); + +// ── filterCatalogEntries ───────────────────────────────────────────────────── + +describe("filterCatalogEntries", () => { + const entries = [ + entry({ id: "kimi", label: "Kimi Code", command: "kimi" }), + entry({ id: "amp", label: "Amp", command: "amp" }), + entry({ id: "omp", label: "Oh My Pi", command: "omp" }), + ]; + + it("returns everything for a blank query", () => { + assert.equal(filterCatalogEntries(entries, " ").length, 3); + }); + + it("matches label case-insensitively", () => { + assert.deepEqual( + filterCatalogEntries(entries, "kIMi").map((e) => e.id), + ["kimi"], + ); + }); + + it("matches command", () => { + assert.deepEqual( + filterCatalogEntries(entries, "omp").map((e) => e.id), + ["omp"], + ); + }); + + it("returns empty for no match", () => { + assert.equal(filterCatalogEntries(entries, "zzz").length, 0); + }); +}); + +// ── stableRowOrder ─────────────────────────────────────────────────────────── + +describe("stableRowOrder", () => { + it("initial order: priority builtins, then ready, then alpha", () => { + const entries = [ + entry({ id: "zeta", label: "Zeta", availability: "available" }), + entry({ + id: "goose", + label: "goose", + availability: "available", + source: "builtin", + }), + entry({ id: "off", label: "Aardvark", canAutoInstall: true }), + entry({ + id: "buzz-agent", + label: "Buzz", + availability: "available", + source: "builtin", + }), + ]; + assert.deepEqual(stableRowOrder([], entries), [ + "buzz-agent", + "goose", + "zeta", + "off", + ]); + }); + + it("keeps previous relative order when availability changes (no reorder on install)", () => { + const before = ["buzz-agent", "zeta", "off"]; + const entries = [ + entry({ id: "off", label: "Aardvark", availability: "available" }), // just installed + entry({ id: "zeta", label: "Zeta", availability: "available" }), + entry({ + id: "buzz-agent", + label: "Buzz", + availability: "available", + source: "builtin", + }), + ]; + assert.deepEqual(stableRowOrder(before, entries), [ + "buzz-agent", + "zeta", + "off", + ]); + }); + + it("drops removed ids and appends newcomers at the end", () => { + const before = ["buzz-agent", "deleted", "zeta"]; + const entries = [ + entry({ id: "zeta", label: "Zeta", availability: "available" }), + entry({ id: "buzz-agent", label: "Buzz", availability: "available" }), + entry({ id: "new-one", label: "New One", availability: "available" }), + ]; + assert.deepEqual(stableRowOrder(before, entries), [ + "buzz-agent", + "zeta", + "new-one", + ]); + }); +}); + +// ── entryStatusLabel ───────────────────────────────────────────────────────── + +describe("entryStatusLabel", () => { + it("config error wins over availability", () => { + assert.equal( + entryStatusLabel( + entry({ + availability: "available", + authStatus: { status: "config_invalid", diagnostic: "boom" }, + }), + ), + "Config error", + ); + }); + + it("maps availability states", () => { + assert.equal( + entryStatusLabel(entry({ availability: "adapter_missing" })), + "Adapter needed", + ); + assert.equal( + entryStatusLabel(entry({ availability: "adapter_outdated" })), + "Update needed", + ); + assert.equal( + entryStatusLabel(entry({ availability: "cli_missing" })), + "CLI needed", + ); + assert.equal( + entryStatusLabel(entry({ availability: "not_installed" })), + "CLI needed", + ); + }); + + it("flags sign-in for available-but-logged-out", () => { + assert.equal( + entryStatusLabel( + entry({ + availability: "available", + authStatus: { status: "logged_out" }, + }), + ), + "Sign-in needed", + ); + }); + + it("is silent for ready + logged in", () => { + assert.equal( + entryStatusLabel( + entry({ + availability: "available", + authStatus: { status: "logged_in" }, + }), + ), + null, + ); + }); +}); + +// ── adapterUpdateWarning ───────────────────────────────────────────────────── + +describe("adapterUpdateWarning", () => { + it("keeps the codex-specific machine-wide caveat for codex", () => { + const copy = adapterUpdateWarning( + entry({ id: "codex", label: "Codex", command: "codex-acp" }), + ); + assert.match(copy, /codex-acp/); + assert.match(copy, /@zed-industries\/codex-acp@0\.16\.0/); + }); + + it("never leaks codex package copy into other runtimes", () => { + const copy = adapterUpdateWarning( + entry({ + id: "claude", + label: "Claude Code", + command: "claude-agent-acp", + }), + ); + assert.match(copy, /claude-agent-acp/); + assert.doesNotMatch(copy, /codex/i); + assert.doesNotMatch(copy, /zed-industries/); + }); + + it("falls back to the label when the command is missing", () => { + const copy = adapterUpdateWarning( + entry({ id: "mystery", label: "Mystery Harness", command: null }), + ); + assert.match(copy, /Mystery Harness/); + assert.doesNotMatch(copy, /codex/i); + }); +}); + +// ── catalogPrimaryAction ───────────────────────────────────────────────────── + +describe("catalogPrimaryAction", () => { + it("no action for ready entries", () => { + assert.deepEqual( + catalogPrimaryAction(entry({ availability: "available" })), + { kind: "none" }, + ); + }); + + it("install for one-click installable", () => { + assert.deepEqual(catalogPrimaryAction(entry({ canAutoInstall: true })), { + kind: "install", + label: "Install", + }); + }); + + it("update label for outdated adapters", () => { + assert.deepEqual( + catalogPrimaryAction( + entry({ availability: "adapter_outdated", canAutoInstall: true }), + ), + { kind: "install", label: "Update" }, + ); + }); + + it("docs fallback when auto-install is unavailable", () => { + assert.deepEqual( + catalogPrimaryAction( + entry({ installInstructionsUrl: "https://example.com" }), + ), + { kind: "docs", label: "Setup guide" }, + ); + }); + + it("docs fallback labels download pages honestly", () => { + assert.deepEqual( + catalogPrimaryAction( + entry({ installInstructionsUrl: "https://cursor.com/downloads" }), + ), + { kind: "docs", label: "Download page" }, + ); + }); + + it("node-gated installs fall back to docs", () => { + assert.deepEqual( + catalogPrimaryAction( + entry({ + canAutoInstall: true, + nodeRequired: true, + installInstructionsUrl: "https://example.com", + }), + ), + { kind: "docs", label: "Setup guide" }, + ); + }); + + it("none when nothing is actionable", () => { + assert.deepEqual(catalogPrimaryAction(entry({})), { kind: "none" }); + }); +}); diff --git a/desktop/src/features/settings/ui/harnessCatalogLogic.ts b/desktop/src/features/settings/ui/harnessCatalogLogic.ts new file mode 100644 index 00000000000..2627135a778 --- /dev/null +++ b/desktop/src/features/settings/ui/harnessCatalogLogic.ts @@ -0,0 +1,226 @@ +/** + * Pure logic for the consolidated Harnesses settings surface and the + * Add-runtimes catalog dialog. + * + * Extracted for deterministic unit-testing — no React, no Tauri, no network. + */ + +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; + +// Builtins that anchor the top of "Your runtimes" — mirrors the old +// DoctorSettingsPanel RUNTIME_SORT_PRIORITY so the Buzz + Goose rows stay +// where users learned to find them. +const ROW_SORT_PRIORITY: Record = { + "buzz-agent": 0, + goose: 1, +}; + +/** + * True when the entry earns a row in "Your runtimes": + * + * - it is ready (`availability === "available"`), or + * - one click of the Install button makes it ready (auto-install works), or + * - the user authored it (`source === "custom"` — owner rows keep their + * edit/delete affordances regardless of readiness). + * + * Everything else needs multi-step setup and belongs in the Add-runtimes + * catalog with a real setup action — NOT an inert row control. + */ +export function isYourHarnessEntry(entry: AcpRuntimeCatalogEntry): boolean { + if (entry.source === "custom") return true; + if (entry.availability === "available") return true; + return entry.canAutoInstall && !entry.nodeRequired; +} + +/** Entries that render as rows in "Your runtimes". */ +export function yourHarnessEntries( + catalog: readonly AcpRuntimeCatalogEntry[], +): AcpRuntimeCatalogEntry[] { + return catalog.filter(isYourHarnessEntry); +} + +/** + * Entries offered in the Add-runtimes catalog: every non-custom entry. + * Ready ones still show (marked as such) so the catalog doubles as a + * browsable inventory, like the Agent Catalog. + */ +export function catalogDialogEntries( + catalog: readonly AcpRuntimeCatalogEntry[], +): AcpRuntimeCatalogEntry[] { + return catalog + .filter((e) => e.source !== "custom") + .sort(compareCatalogEntries); +} + +/** Needs-setup entries first (that's why the user opened the dialog), then + * ready ones; alphabetical within each group. */ +function compareCatalogEntries( + a: AcpRuntimeCatalogEntry, + b: AcpRuntimeCatalogEntry, +): number { + const aReady = a.availability === "available" ? 1 : 0; + const bReady = b.availability === "available" ? 1 : 0; + if (aReady !== bReady) return aReady - bReady; + return a.label.localeCompare(b.label); +} + +/** + * Splits catalog entries into the two accordion sections of the Add-runtimes + * list: "Setup" (needs action — the reason the user opened the dialog) and + * "Installed" (already ready, collapsed by default). Relative order within + * each group is preserved from the input. + */ +export function groupCatalogEntries( + entries: readonly AcpRuntimeCatalogEntry[], +): { + setup: AcpRuntimeCatalogEntry[]; + installed: AcpRuntimeCatalogEntry[]; +} { + return { + setup: entries.filter((e) => e.availability !== "available"), + installed: entries.filter((e) => e.availability === "available"), + }; +} + +/** Case-insensitive catalog search across label, id, and command. */ +export function filterCatalogEntries( + entries: readonly AcpRuntimeCatalogEntry[], + query: string, +): AcpRuntimeCatalogEntry[] { + const q = query.trim().toLowerCase(); + if (!q) return [...entries]; + return entries.filter( + (e) => + e.label.toLowerCase().includes(q) || + e.id.toLowerCase().includes(q) || + (e.command ?? "").toLowerCase().includes(q), + ); +} + +function compareInitialRows( + a: AcpRuntimeCatalogEntry, + b: AcpRuntimeCatalogEntry, +): number { + const aPriority = ROW_SORT_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER; + const bPriority = ROW_SORT_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER; + if (aPriority !== bPriority) return aPriority - bPriority; + const aOn = a.availability === "available" ? 0 : 1; + const bOn = b.availability === "available" ? 0 : 1; + if (aOn !== bOn) return aOn - bOn; + return a.label.localeCompare(b.label); +} + +/** + * Stable row ordering for "Your runtimes". + * + * First render sorts priority builtins first, then ready-before-needs-setup, + * then alphabetically. Subsequent renders KEEP the previous relative order + * for ids that are still present — a row that just finished installing must + * not jump around under the pointer — and append newcomers (e.g. a harness + * just added from the catalog) using the initial comparator. + * + * Returns the ordered id list; callers map ids back to entries. + */ +export function stableRowOrder( + previousOrder: readonly string[], + entries: readonly AcpRuntimeCatalogEntry[], +): string[] { + const present = new Set(entries.map((e) => e.id)); + const kept = previousOrder.filter((id) => present.has(id)); + const keptSet = new Set(kept); + const appended = entries + .filter((e) => !keptSet.has(e.id)) + .sort(compareInitialRows) + .map((e) => e.id); + return [...kept, ...appended]; +} + +/** Human status label for a catalog entry; null when nothing needs saying. */ +export function entryStatusLabel(entry: AcpRuntimeCatalogEntry): string | null { + if (entry.authStatus.status === "config_invalid") return "Config error"; + switch (entry.availability) { + case "adapter_missing": + return "Adapter needed"; + case "adapter_outdated": + return "Update needed"; + case "cli_missing": + case "not_installed": + return "CLI needed"; + case "available": + return entry.authStatus.status === "logged_out" ? "Sign-in needed" : null; + default: + return null; + } +} + +/** + * Body copy for the confirmation dialog shown before replacing an + * already-installed (but outdated) adapter. + * + * Codex carries a specific machine-wide caveat about the legacy Zed adapter + * contract; every other runtime gets generic, runtime-derived copy — Codex + * package names must never appear for another runtime. + */ +export function adapterUpdateWarning(entry: AcpRuntimeCatalogEntry): string { + if (entry.id === "codex") { + return ( + "This replaces the machine-wide codex-acp adapter. Older Buzz " + + "releases using the legacy adapter may lose community access until " + + "@zed-industries/codex-acp@0.16.0 is restored." + ); + } + const adapter = entry.command?.trim() || entry.label; + return ( + `This replaces the machine-wide ${adapter} adapter. Other tools using ` + + "the currently installed adapter will switch to the updated version." + ); +} + +export type CatalogPrimaryAction = + | { kind: "install"; label: string } + | { kind: "docs"; label: string } + | { kind: "none" }; + +/** + * True when the vendor's install link is a plain download page (e.g. + * cursor.com/downloads, kimi.ai/download) rather than written setup docs — + * "Setup guide" would over-promise for those. + */ +export function isDownloadPageUrl(url: string): boolean { + try { + return /download/i.test(new URL(url.trim()).pathname); + } catch { + return false; + } +} + +/** Label for a link that opens `installInstructionsUrl`. */ +export function installLinkLabel(entry: AcpRuntimeCatalogEntry): string { + return isDownloadPageUrl(entry.installInstructionsUrl) + ? "Download page" + : "Setup guide"; +} + +/** + * Primary action for the catalog detail pane. + * + * - Ready → no action (the entry already has a row in Your runtimes). + * - One-click installable → Install. + * - Otherwise → open the vendor's setup guide or download page, when one + * exists. + */ +export function catalogPrimaryAction( + entry: AcpRuntimeCatalogEntry, +): CatalogPrimaryAction { + if (entry.availability === "available") return { kind: "none" }; + if (entry.canAutoInstall && !entry.nodeRequired) { + return { + kind: "install", + label: entry.availability === "adapter_outdated" ? "Update" : "Install", + }; + } + if (entry.installInstructionsUrl.trim().length > 0) { + return { kind: "docs", label: installLinkLabel(entry) }; + } + return { kind: "none" }; +} diff --git a/desktop/src/features/settings/ui/harnessGalleryLogic.test.mjs b/desktop/src/features/settings/ui/harnessGalleryLogic.test.mjs index d7150f1ef3a..cbf8b6d353e 100644 --- a/desktop/src/features/settings/ui/harnessGalleryLogic.test.mjs +++ b/desktop/src/features/settings/ui/harnessGalleryLogic.test.mjs @@ -2,8 +2,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { - sortedPresetEntries, - customEntries, isEditableEntry, countAgentsReferencingHarness, deleteHarnessConfirmMessage, @@ -36,130 +34,6 @@ function entry(overrides = {}) { }; } -// ── sortedPresetEntries ─────────────────────────────────────────────────────── - -describe("sortedPresetEntries", () => { - it("returns only preset-source entries", () => { - const catalog = [ - entry({ id: "p1", source: "preset" }), - entry({ id: "c1", source: "custom" }), - entry({ id: "b1", source: "builtin" }), - ]; - const result = sortedPresetEntries(catalog); - assert.equal(result.length, 1); - assert.equal(result[0].id, "p1"); - }); - - it("places detected (available) entries before not-installed", () => { - const catalog = [ - entry({ - id: "not-there", - source: "preset", - availability: "not_installed", - label: "Alpha", - }), - entry({ - id: "detected", - source: "preset", - availability: "available", - label: "Beta", - }), - ]; - const result = sortedPresetEntries(catalog); - assert.equal(result[0].id, "detected", "detected entry must come first"); - assert.equal(result[1].id, "not-there"); - }); - - it("sorts alphabetically within detected group", () => { - const catalog = [ - entry({ - id: "z", - source: "preset", - availability: "available", - label: "Zebra", - }), - entry({ - id: "a", - source: "preset", - availability: "available", - label: "Aardvark", - }), - ]; - const result = sortedPresetEntries(catalog); - assert.equal(result[0].id, "a"); - assert.equal(result[1].id, "z"); - }); - - it("sorts alphabetically within not-installed group", () => { - const catalog = [ - entry({ - id: "z", - source: "preset", - availability: "not_installed", - label: "Zebra", - }), - entry({ - id: "a", - source: "preset", - availability: "not_installed", - label: "Aardvark", - }), - ]; - const result = sortedPresetEntries(catalog); - assert.equal(result[0].id, "a"); - assert.equal(result[1].id, "z"); - }); - - it("returns empty array when no preset entries", () => { - const catalog = [entry({ source: "custom" }), entry({ source: "builtin" })]; - assert.deepEqual(sortedPresetEntries(catalog), []); - }); - - it("does not mutate the input array", () => { - const catalog = [ - entry({ - id: "z", - source: "preset", - availability: "available", - label: "Z", - }), - entry({ - id: "a", - source: "preset", - availability: "available", - label: "A", - }), - ]; - const original = [...catalog]; - sortedPresetEntries(catalog); - assert.deepEqual( - catalog.map((e) => e.id), - original.map((e) => e.id), - "input array must not be mutated", - ); - }); -}); - -// ── customEntries ───────────────────────────────────────────────────────────── - -describe("customEntries", () => { - it("returns only custom-source entries", () => { - const catalog = [ - entry({ id: "p1", source: "preset" }), - entry({ id: "c1", source: "custom" }), - entry({ id: "c2", source: "custom" }), - ]; - const result = customEntries(catalog); - assert.equal(result.length, 2); - assert.ok(result.every((e) => e.source === "custom")); - }); - - it("returns empty when no custom entries", () => { - const catalog = [entry({ source: "preset" }), entry({ source: "builtin" })]; - assert.deepEqual(customEntries(catalog), []); - }); -}); - // ── isEditableEntry ─────────────────────────────────────────────────────────── describe("isEditableEntry", () => { diff --git a/desktop/src/features/settings/ui/harnessGalleryLogic.ts b/desktop/src/features/settings/ui/harnessGalleryLogic.ts index d3c782c530c..0c67ebf65d7 100644 --- a/desktop/src/features/settings/ui/harnessGalleryLogic.ts +++ b/desktop/src/features/settings/ui/harnessGalleryLogic.ts @@ -1,39 +1,11 @@ /** - * Pure logic helpers for the harness gallery (HarnessManagementCard). + * Pure logic helpers for custom-harness deletion (blast-radius confirmation). * * Extracted for deterministic unit-testing — no React, no Tauri, no network. */ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -/** - * Filter catalog entries to preset-only, sorted detected-first then - * alphabetically within each group. - * - * "Detected" means availability === "available". This mirrors the - * React.useMemo sort inside HarnessManagementCard. - */ -export function sortedPresetEntries( - catalog: readonly AcpRuntimeCatalogEntry[], -): AcpRuntimeCatalogEntry[] { - const presets = catalog.filter((e) => e.source === "preset"); - return [...presets].sort((a, b) => { - const aDetected = a.availability === "available" ? 0 : 1; - const bDetected = b.availability === "available" ? 0 : 1; - if (aDetected !== bDetected) return aDetected - bDetected; - return a.label.localeCompare(b.label); - }); -} - -/** - * Filter catalog entries to custom-only. - */ -export function customEntries( - catalog: readonly AcpRuntimeCatalogEntry[], -): AcpRuntimeCatalogEntry[] { - return catalog.filter((e) => e.source === "custom"); -} - /** * Returns true iff the given catalog entry is editable by the user. * Only `source === "custom"` entries are editable/deletable. diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 50298a92d18..a673492ef1e 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -91,7 +91,7 @@ export function AppSidebarPrimaryMenu({ }: AppSidebarPrimaryMenuProps) { return ( diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index b15e0bab712..386ee206917 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -370,7 +370,7 @@ export function CommunityRail({ return (