diff --git a/.env.example b/.env.example index db5a7ea25c..3dc54856e7 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) # ----------------------------------------------------------------------------- @@ -105,7 +109,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 0000000000..f36b1615d0 --- /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 325a485102..238878c749 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 ad7f77f6bf..d4826d985f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,21 +48,30 @@ 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' + - 'scripts/mobile-worktree-clean.sh' - 'scripts/publish-mobile-release-candidate.sh' - 'scripts/release-rulesets.sh' - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' + - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract @@ -71,6 +80,10 @@ jobs: run: | scripts/test-mobile-release-contract.sh 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 @@ -125,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 @@ -208,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: @@ -335,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 \ @@ -666,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 @@ -745,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 @@ -778,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 @@ -816,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 31080652ea..52f21b28bc 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/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 18d476e400..9806443378 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -21,7 +21,7 @@ jobs: name: Build Linux canary if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 timeout-minutes: 60 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3e20c393d..b87e9c8c08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -476,7 +476,7 @@ jobs: if: github.repository == 'block/buzz' runs-on: ubuntu-latest # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. - container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 needs: setup timeout-minutes: 60 permissions: @@ -498,7 +498,7 @@ jobs: env: DEBIAN_FRONTEND: noninteractive run: | - # Must run first: bare ubuntu:22.04 ships without curl, wget, git, or + # Must run first: bare ubuntu:24.04 ships without curl, wget, git, or # ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs # both), and actions/checkout falls back to a REST tarball without git. # Running as root — no sudo needed. @@ -629,7 +629,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - - name: Fix AppImage (remove infra libs, symlink system GStreamer) + - name: Fix AppImage (remove infra libs, shim host GStreamer) run: | mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f) if [[ ${#APPIMAGES[@]} -eq 0 ]]; then diff --git a/AGENTS.md b/AGENTS.md index 3edfb34f60..7ff0eb4d47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,8 @@ re-install hooks after env changes. Before agents run Git or hooks, activate the repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook commands to compensate for an unconfigured shell `PATH`. +**Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push. + Additional rules: - No `unsafe` code - Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types @@ -341,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 @@ -553,6 +565,15 @@ To run the app locally (starts Docker, relay, iOS simulator automatically): just mobile-dev ``` +When run from a git worktree, `just mobile-dev` (and `just +mobile-build-android`) give the debug build a per-worktree app identifier +(keyed to the worktree directory name) and a branch-labelled app name via +`scripts/mobile-worktree-overrides.sh`, so builds from multiple worktrees +install side by side. Release builds are unaffected. `just mobile-clean` +removes stale worktree-suffixed installs from simulators/emulators. See +[mobile/README.md](mobile/README.md) for direct Xcode / Android Studio +usage. + ### Testing Conventions - Prefer **widget tests** over unit tests for UI components — test the diff --git a/CHANGELOG.md b/CHANGELOG.md index 1223936504..956faa1ed3 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 565d740129..db0aea637f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,29 @@ 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. +### 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. --- @@ -75,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 @@ -185,7 +239,7 @@ just ci ``` This is the same check that runs in CI. PRs that fail `just ci` will not be -merged. +merged. If `just ci` fails on formatting, `just fix-all` fixes it in one shot (`rustfmt` + Tauri fmt + desktop, web, and mobile formatters). --- @@ -282,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. -- We prioritize focused PRs that follow this guide and review as capacity allows. +### 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 + +- 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 9d0190868d..3b60dc4579 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 3499285f91..3ac7ee4cce 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 661be6c3a7..d883ac6b01 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/Justfile b/Justfile index 701a1f06b2..bcef8983bc 100644 --- a/Justfile +++ b/Justfile @@ -620,11 +620,12 @@ mobile-check: mobile-test: unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test -# Compile an unsigned Android debug APK +# Compile an unsigned Android debug APK (worktree-aware debug identity) mobile-build-android: + ./scripts/mobile-worktree-overrides.sh unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub -# Run the mobile app on iOS simulator +# Run the mobile app on iOS simulator (worktree-aware debug identity) mobile-dev: #!/usr/bin/env bash set -euo pipefail @@ -632,10 +633,15 @@ mobile-dev: open -a Simulator sleep 3 fi + ./scripts/mobile-worktree-overrides.sh cd {{mobile_dir}} unset GIT_DIR GIT_WORK_TREE flutter run +# Uninstall stale worktree-suffixed Buzz debug installs (production apps kept) +mobile-clean: + ./scripts/mobile-worktree-clean.sh + # ─── Database ───────────────────────────────────────────────────────────────── # Apply database migrations diff --git a/README.md b/README.md index 89a131e73e..72af92ce13 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL ┌─────────────────────────────────────────────────────────────────────────┐ │ buzz-relay │ │ NIP-01 · NIP-42 auth · channel/DM/media/workflow/git REST · audit log │ -└───┬──────────────────────────┬──────────────────────────┬──────────────┘ +└───┬──────────────────────────┬──────────────────────────┬───────────────┘ │ │ │ ┌──▼───────────┐ ┌──────▼──────┐ ┌───────▼─────┐ │ Postgres │ │ Redis │ │ S3/MinIO │ diff --git a/RELEASING.md b/RELEASING.md index 9785122aad..063b813e2c 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 49202e9778..f39ecc33c5 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 f72747c0a7..93ebceeb14 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 1aab0cb9ac..6c10837758 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 002ce456b3..3c965dd2d8 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 f1e91d022b..b6f5601a82 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( - "--gui", action="store_true", + "--upload", + action="store_true", + help="Upload to Harbor Hub when the job finishes", + ) + parser.add_argument( + "--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 6fd43ea6fb..6eaf8d6af0 100755 --- a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py +++ b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py @@ -45,64 +45,101 @@ AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp") # 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 b423e8aa47..1b79d233b9 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 6354e9a587..3d1c81364f 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.agents.base import BaseAgent 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 @@ class BuzzOrchestraAgent(BaseAgent): 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 3909f081f5..149a5295a7 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 .manifest import AgentClass, ExperimentManifest 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 @@ class BuzzContainerRuntime: 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 @@ class BuzzContainerRuntime: ) 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 @@ class BuzzContainerRuntime: 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 @@ class BuzzContainerRuntime: ) @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 @@ class BuzzContainerRuntime: # 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 @@ class BuzzContainerRuntime: ) -> 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 @@ class BuzzContainerRuntime: 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 @@ class BuzzContainerRuntime: "", 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 @@ class BuzzContainerRuntime: 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 ed2bb31cc9..bd11f193ca 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 @@ class BuzzCli: 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 cfda6b59fa..d8f380387d 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 @@ class BuzzTrialProvisioner: 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 2d88e339f5..e0c6d32ec4 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 0f82578369..0ac794e0fa 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 hashlib import json import coincurve + from harbor_buzz_testbed.keys import ( compute_auth_tag, encode_nsec, @@ -21,8 +22,10 @@ RUST_TAG = [ "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 5a6b40d8cf..711b877ab4 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 uuid 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 9620be4bc8..e784de5825 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 json import coincurve import pytest + from harbor_buzz_testbed.provisioner import ( BuzzTrialProvisioner, ProvisioningError, @@ -17,13 +18,13 @@ OWNER_SECRET = "0" * 63 + "3" 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 bd0bcaf2dd..b1de094d76 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 62c6047ab3..b305344c51 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 @@ class Runtime: 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 8669fe980c..ebf0eb4b5d 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 @@ from pathlib import Path 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 @@ from harbor_buzz_orchestra.container_runtime import ( 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 @@ async def test_wait_for_agents_ready_requires_every_channel_subscription(tmp_pat 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 test_wait_for_agents_ready_requires_every_channel_subscription(tmp_pat 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 36533db3bf..f8230036b3 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 ed048ee5d9..451de72e79 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 07e8834efb..4df063726c 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 07e8834efb..4df063726c 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 07e8834efb..4df063726c 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 07e8834efb..4df063726c 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/README.md b/crates/buzz-acp/README.md index d9cd362cb8..e6164b02dd 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -25,13 +25,22 @@ export PATH="$PWD/target/release:$PATH" ## Generating Keys -Each agent needs a Nostr keypair — this is the agent's identity in Buzz. Use `buzz-admin` to mint one: +Each agent needs a Nostr keypair — this is the agent's identity in Buzz. Use `buzz-admin` to generate one: ```bash -cargo run -p buzz-admin -- mint-token --name "my-agent" --scopes "messages:read,messages:write,channels:read" +cargo run -p buzz-admin -- generate-key ``` -This prints an `nsec1...` private key and an API token. **Save both immediately — they're shown only once.** +This prints a public and secret key pair as hex. **Save the secret key immediately — it is not stored and cannot be recovered.** Set `BUZZ_PRIVATE_KEY` to the secret key to act as this identity. + +Then register the agent's public key as a relay member so it can read and publish: + +```bash +BUZZ_RELAY_PRIVATE_KEY= \ + cargo run -p buzz-admin -- add-member --pubkey +``` + +`add-member` publishes a kind:13534 membership event, so the relay needs a stable signing key: set `BUZZ_RELAY_PRIVATE_KEY` in the relay's environment (uncomment it in `.env`) and restart the relay before running this. > **Running multiple agents?** Mint a separate keypair for each. Every agent needs its own identity. @@ -252,6 +261,65 @@ Each channel has at most one prompt in flight. Multiple channels can be processe > **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel. +## Bring Your Own Harness (BYOH) + +Buzz Desktop supports registering any ACP-speaking agent tool as a selectable runtime without a PR. + +### How it works + +**Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden. + +**Tier-2 — preset catalog** (Cursor, Oh My Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead. + +> **Note — OpenClaw:** `openclaw acp` is a Gateway-backed bridge; PATH availability shows "Available" even when the OpenClaw Gateway daemon is not running. This is expected tier-2 semantics (same class as a preset with unconfigured auth). The Gateway URL is configured via `OPENCLAW_GATEWAY_URL` (or the equivalent env var from OpenClaw's docs) — set it in the agent's **env vars** in Edit Agent, not in the definition env (the preset definition carries no env entries). Note that `openclaw acp` executes tools inside the Gateway daemon, not the Desktop process, so Desktop-injected `BUZZ_*` env vars do NOT reach the execution locus unless you also set them on the Gateway's own environment. + +**Tier-3 — user custom harnesses**: JSON files in `/custom_harnesses/` that the user can create from the Settings UI or drop in directly. Each file describes one harness — no install scripts. + +### Custom harness JSON schema + +```json +{ + "id": "my-agent", + "label": "My Agent", + "command": "my-agent-bin", + "args": ["acp"], + "env": { + "MY_AGENT_MODE": "acp" + }, + "installInstructionsUrl": "https://example.com/docs", + "installHint": "Download from example.com" +} +``` + +Fields: +- `id` — `[a-z0-9_][a-z0-9_-]*` (used as the runtime picker value and file name) +- `label` — human-readable name shown in the UI +- `command` — the executable name or absolute path (must be non-empty) +- `args` — optional default CLI arguments (array); instance-level args override this when non-empty +- `env` — optional environment variables injected at spawn time (definition env is a floor; user/persona/global env overrides it; Buzz-reserved keys like `BUZZ_MANAGED_AGENT` are always stripped and cannot be overridden) +- `installInstructionsUrl` / `installHint` — shown when the binary is not on PATH + +Invalid files (bad JSON, unknown id, empty command) are skipped with a warning and do not break discovery for other entries. + +### Security guarantees + +- No install shell commands in preset or custom definitions — only the user's own PATH is consulted. +- `can_auto_install` is always `false` for preset and custom entries. +- No user-supplied icon URLs — icons are bundled assets keyed by id in `RuntimeIcon.tsx`. +- `BUZZ_MANAGED_AGENT` and other Buzz identity keys cannot be overridden by `env` in a custom definition; they are stripped before merging. + +### Adding a preset (contributor guide) + +To add a new runtime to the tier-2 gallery: + +1. **Verify the ACP entrypoint** from the vendor's own documentation — do not rely on a PR description alone. Test with the actual binary. +2. **Add a `HarnessDefinition` entry** to the `PRESET_HARNESSES` slice in `desktop/src-tauri/src/managed_agents/discovery.rs`. Fill `id`, `label`, `command`, `args`, `install_instructions_url`, `install_hint`. Leave `env` empty unless the harness requires a specific env var to enable ACP mode. +3. **Add the preset id to `BUILTIN_IDS`** in `desktop/src-tauri/src/managed_agents/custom_harnesses.rs` so custom JSON files cannot shadow it. +4. **Add a bundled logo** (64×64 PNG or optimised SVG) to `desktop/public/harness-logos/.png` and add a corresponding entry to `PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`. Record the source and license in `desktop/public/harness-logos/CREDITS.md`. Only bundle a mark whose upstream license permits redistribution; skipping this step is caught by `presetLogos.test.mjs`, which asserts every `PRESET_HARNESSES` id has a mapped logo that exists on disk. +5. Run `cargo test --lib` and `just desktop-typecheck` to verify everything compiles. + +The built-in `BUILTIN_IDS` set (`goose`, `claude`, `codex`, `buzz-agent`, and all current preset ids) is the reserved namespace; every other id is available for custom harnesses. + ## Using Any ACP Agent The harness works with any agent that implements the [ACP spec](https://agentclientprotocol.com/) over stdio. The requirements are: diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 78db7ff718..8a698954a0 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/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index c42e65cb83..e360d24982 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -40,6 +40,8 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat - Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently. - Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery. +- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. +- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. - Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm. ### Callback Mentions diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..dab61be30a 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 0230ea0875..403512a322 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); @@ -3604,6 +3625,22 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally")); assert!(prompt.contains("buzz messages send ... --content -")); } + + #[test] + fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("--mention ")); + assert!(prompt.contains("every presentation-only name that should notify")); + assert!( + prompt.contains("permits unresolved or ambiguous `@Name` text as presentation-only") + ); + assert!(prompt.contains("success JSON's `mention_pubkeys`")); + assert!(prompt.contains("no follow-up verification command is needed")); + assert!(prompt.contains("stops before sending")); + assert!(prompt + .contains("add them explicitly with `buzz channels add-member` only when authorized")); + assert!(prompt.contains("never changes membership automatically")); + } } fn default_heartbeat_prompt() -> String { @@ -3782,7 +3819,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 +4064,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 +4216,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 +5023,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 +5087,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 +5244,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 cc537f8683..038f8a714c 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 a4f7abd3b3..8cca9c96f8 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 730e87b2e8..ed04daca2c 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/catalog.rs b/crates/buzz-agent/src/catalog.rs index fa57935717..aa2a121c99 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -37,8 +37,11 @@ pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = /// This is the list of models advertised by `session/new` when /// `discover_databricks_models` returns an error (e.g., no token available). /// -/// - `DatabricksV2` falls back to [`DATABRICKS_V2_KNOWN_MODELS`] so the -/// model-picker is always populated for AI Gateway v2 users. +/// - `DatabricksV2` falls back to the configured model plus +/// [`DATABRICKS_V2_KNOWN_MODELS`] so the model-picker is always populated for +/// AI Gateway v2 users. The configured model leads: without it a fallback +/// catalog can omit the very model the agent is running, leaving the picker +/// unable to represent the current selection. /// - Legacy `Databricks` falls back to only the configured model — the /// `DATABRICKS_V2_KNOWN_MODELS` IDs are AI Gateway v2 endpoints that the /// `/serving-endpoints/{model}/invocations` API may not serve. @@ -46,25 +49,62 @@ pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = /// Extracting this as a pure function makes the split testable without /// spawning an async runtime or making network calls. pub fn discovery_failure_fallback(provider: Provider, configured_model: &str) -> Vec { + // `resolve_model` does not trim, so a padded `DATABRICKS_MODEL` reaches here: + // normalize once, or the dedupe below misses and the picker lists the model + // twice (once padded, once from the known slate). + let configured_model = configured_model.trim(); + let configured = ModelEntry { + id: configured_model.to_string(), + name: configured_model.to_string(), + }; match provider { - Provider::DatabricksV2 => DATABRICKS_V2_KNOWN_MODELS - .iter() - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }) - .collect(), - Provider::Databricks => vec![ModelEntry { - id: configured_model.to_string(), - name: configured_model.to_string(), - }], - _ => vec![ModelEntry { - id: configured_model.to_string(), - name: configured_model.to_string(), - }], + Provider::DatabricksV2 => { + let mut entries = Vec::with_capacity(DATABRICKS_V2_KNOWN_MODELS.len() + 1); + if !configured_model.is_empty() { + entries.push(configured); + } + entries.extend( + DATABRICKS_V2_KNOWN_MODELS + .iter() + .filter(|id| **id != configured_model) + .map(|id| ModelEntry { + id: id.to_string(), + name: id.to_string(), + }), + ); + entries + } + Provider::Databricks => vec![configured], + _ => vec![configured], } } +/// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves +/// chat/completions traffic. +/// +/// The v1 `serving-endpoints` payload carries `task`, so [`parse_v1_endpoints`] +/// can filter on it directly. The v2 `ai-gateway/v2/endpoints` payload carries +/// no task or readiness field at all, so the only signal available here is the +/// endpoint name. Embedding endpoints are the one family that reliably cannot +/// serve a chat request — they reject it with +/// `API type 'mlflow/v1/chat/completions' is not supported by ''` — so +/// they are dropped rather than offered as selectable models. +/// +/// Deliberately narrow: image-capable endpoints (e.g. +/// `databricks-gemini-3-pro-image`) do answer chat requests, so they stay. Any +/// name this heuristic does not recognise is kept — preferring to include over +/// silently dropping, matching [`parse_v1_endpoints`]. +pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if lower.contains("embedding") { + return false; + } + // Segment match so `bge`/`gte` cannot fire on a substring of a longer word. + !lower + .split('-') + .any(|segment| matches!(segment, "bge" | "gte")) +} + /// Discover available models for a Databricks provider. /// /// Returns a non-empty `Vec` on success. Returns @@ -197,7 +237,7 @@ async fn fetch_v2_models( host: &str, bearer: &str, ) -> Result, AgentError> { - let mut all_models: Vec = Vec::new(); + let mut all_endpoints: Vec = Vec::new(); let mut page_token: Option = None; let base_url = format!("{host}/api/ai-gateway/v2/endpoints"); @@ -235,8 +275,8 @@ async fn fetch_v2_models( )) })?; - let (page_models, next) = parse_v2_endpoints_page(&json)?; - all_models.extend(page_models); + let (page_endpoints, next) = parse_v2_endpoints_page(&json)?; + all_endpoints.extend(page_endpoints); match next { Some(tok) if Some(&tok) != page_token.as_ref() => page_token = Some(tok), @@ -245,26 +285,74 @@ async fn fetch_v2_models( } // Fall back to known-model list if the API returned nothing. - if all_models.is_empty() { - all_models = DATABRICKS_V2_KNOWN_MODELS + if all_endpoints.is_empty() { + return Ok(DATABRICKS_V2_KNOWN_MODELS .iter() .map(|id| ModelEntry { id: id.to_string(), name: id.to_string(), }) - .collect(); + .collect()); } - Ok(all_models) + sort_v2_endpoints_newest_first(&mut all_endpoints); + + Ok(all_endpoints + .into_iter() + .map(|endpoint| endpoint.entry) + .collect()) +} + +/// A v2 gateway endpoint plus the key discovery orders the catalog by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct V2Endpoint { + pub(crate) entry: ModelEntry, + /// `created_timestamp` as epoch milliseconds. `None` when the field is + /// absent or unparseable — those sort last rather than jumping the queue. + pub(crate) created_ms: Option, +} + +/// Read `created_timestamp` from one endpoint object. +/// +/// The gateway sends epoch milliseconds as a JSON *string* +/// (`"created_timestamp": "1699610000000"`); accept a bare number too, so a +/// wire-shape change doesn't silently drop every endpoint to the bottom. +fn endpoint_created_ms(endpoint: &serde_json::Value) -> Option { + let value = endpoint.get("created_timestamp")?; + value + .as_i64() + .or_else(|| value.as_str()?.trim().parse::().ok()) +} + +/// Order the catalog newest-first, breaking ties by name. +/// +/// The gateway returns endpoints in two phases — Databricks-managed first, then +/// workspace-created — each alphabetical by name, which buries a brand-new +/// frontier model deep in the list. Newest-first puts the models people are +/// reaching for at the top of the picker. +/// +/// Endpoints with no usable timestamp sort last, and the name tiebreak keeps the +/// result stable: several managed endpoints share one placeholder timestamp, so +/// without it their relative order would be arbitrary. +pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) { + endpoints.sort_by(|a, b| { + // `None` < `Some(_)`, so reversing puts timestamped endpoints first. + b.created_ms + .cmp(&a.created_ms) + .then_with(|| a.entry.name.cmp(&b.entry.name)) + }); } /// Parse one page of a `GET api/ai-gateway/v2/endpoints` response. /// -/// Returns `(models, next_page_token)`. An empty or absent `next_page_token` -/// signals the last page. +/// Returns `(endpoints, next_page_token)`. An empty or absent `next_page_token` +/// signals the last page. Endpoints that cannot serve chat traffic are dropped +/// (see [`is_chat_capable_endpoint`]) so the model picker only offers models the +/// agent can actually run. Page order is preserved here; the caller sorts once +/// every page is in (see [`sort_v2_endpoints_newest_first`]). pub(crate) fn parse_v2_endpoints_page( json: &serde_json::Value, -) -> Result<(Vec, Option), AgentError> { +) -> Result<(Vec, Option), AgentError> { let endpoints = json .get("endpoints") .and_then(|v| v.as_array()) @@ -279,9 +367,15 @@ pub(crate) fn parse_v2_endpoints_page( .iter() .filter_map(|endpoint| { let name = endpoint.get("name")?.as_str()?.to_string(); - Some(ModelEntry { - id: name.clone(), - name, + if !is_chat_capable_endpoint(&name) { + return None; + } + Some(V2Endpoint { + entry: ModelEntry { + id: name.clone(), + name, + }, + created_ms: endpoint_created_ms(endpoint), }) }) .collect(); @@ -356,7 +450,7 @@ mod tests { }); let (models, next) = parse_v2_endpoints_page(&json).unwrap(); - let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); assert_eq!( ids, vec![ @@ -399,4 +493,139 @@ mod tests { "got: {err}" ); } + + #[test] + fn v2_parse_drops_embedding_endpoints() { + // The v2 payload carries no `task`, so embedding endpoints are only + // recognisable by name. They reject chat requests, so offering them in + // the picker can only produce a 400 at send time. + let json = serde_json::json!({ + "endpoints": [ + {"name": "databricks-bge-large-en"}, + {"name": "databricks-gte-large-en"}, + {"name": "databricks-qwen3-embedding-0-6b"}, + {"name": "databricks-claude-opus-5"}, + {"name": "databricks-gemini-3-pro-image"}, + ] + }); + + let (models, _) = parse_v2_endpoints_page(&json).unwrap(); + let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); + // Image endpoints DO answer chat requests, so they are retained. + assert_eq!( + ids, + vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image"] + ); + } + + #[test] + fn v2_parse_reads_created_timestamp_in_either_wire_shape() { + // The gateway sends epoch ms as a string; a bare number must work too. + let json = serde_json::json!({ + "endpoints": [ + {"name": "string-ts", "created_timestamp": "1784932442251"}, + {"name": "number-ts", "created_timestamp": 1784932442251i64}, + {"name": "junk-ts", "created_timestamp": "not-a-number"}, + {"name": "no-ts"}, + ] + }); + + let (models, _) = parse_v2_endpoints_page(&json).unwrap(); + let stamps: Vec> = models.iter().map(|m| m.created_ms).collect(); + assert_eq!( + stamps, + vec![Some(1784932442251), Some(1784932442251), None, None,] + ); + } + + #[test] + fn v2_endpoints_sort_newest_first_then_by_name() { + // Mirrors the real catalog: the gateway pages Databricks-managed + // endpoints first, then workspace-created ones, each alphabetical — so + // the newest model is buried mid-list until this sort runs. + let json = serde_json::json!({ + "endpoints": [ + {"name": "databricks-claude-opus-5", "created_timestamp": "1784851200000"}, + {"name": "databricks-gpt-5-6-sol", "created_timestamp": "1784073600000"}, + {"name": "databricks-gpt-5-6-luna", "created_timestamp": "1784073600000"}, + {"name": "databricks-llama-4-maverick", "created_timestamp": "1699610000000"}, + {"name": "goose-claude-opus-5", "created_timestamp": "1784932442251"}, + {"name": "endpoint-without-timestamp"}, + ] + }); + + let (mut models, _) = parse_v2_endpoints_page(&json).unwrap(); + sort_v2_endpoints_newest_first(&mut models); + + let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); + assert_eq!( + ids, + vec![ + // Newest first, across both pagination phases. + "goose-claude-opus-5", + "databricks-claude-opus-5", + // Same timestamp — the name tiebreak keeps this deterministic. + "databricks-gpt-5-6-luna", + "databricks-gpt-5-6-sol", + "databricks-llama-4-maverick", + // No usable timestamp sorts last, never first. + "endpoint-without-timestamp", + ] + ); + } + + #[test] + fn is_chat_capable_endpoint_keeps_unrecognised_names() { + // Prefer including over silently dropping — an unknown family is kept. + assert!(is_chat_capable_endpoint("databricks-glm-5-2")); + assert!(is_chat_capable_endpoint("some-teams-custom-endpoint")); + // `bge`/`gte` match as whole segments only, never as substrings. + assert!(is_chat_capable_endpoint("databricks-budget-gtex-model")); + assert!(!is_chat_capable_endpoint("databricks-bge-large-en")); + assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); + assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); + } + + #[test] + fn v2_discovery_failure_fallback_leads_with_configured_model() { + let result = discovery_failure_fallback(Provider::DatabricksV2, "databricks-claude-opus-5"); + let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); + + // The running model must be representable in the picker even when + // discovery failed, so it leads the fallback catalog. + assert_eq!(ids.first(), Some(&"databricks-claude-opus-5")); + for known in DATABRICKS_V2_KNOWN_MODELS { + assert!(ids.contains(known), "fallback must retain '{known}'"); + } + } + + #[test] + fn v2_discovery_failure_fallback_does_not_duplicate_configured_model() { + let configured = DATABRICKS_V2_KNOWN_MODELS[0]; + let result = discovery_failure_fallback(Provider::DatabricksV2, configured); + let occurrences = result.iter().filter(|m| m.id == configured).count(); + assert_eq!(occurrences, 1, "got: {result:?}"); + assert_eq!(result.len(), DATABRICKS_V2_KNOWN_MODELS.len()); + } + + #[test] + fn v2_discovery_failure_fallback_tolerates_blank_configured_model() { + for configured in ["", " "] { + let result = discovery_failure_fallback(Provider::DatabricksV2, configured); + let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); + } + } + + #[test] + fn v2_discovery_failure_fallback_dedupes_a_padded_configured_model() { + // `DATABRICKS_MODEL=" databricks-gpt-5-5 "` reaches here untrimmed, and an + // untrimmed comparison would list the model twice — once padded, once from + // the known slate. + let configured = DATABRICKS_V2_KNOWN_MODELS[0]; + let result = + discovery_failure_fallback(Provider::DatabricksV2, &format!(" {configured} ")); + let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); + } } diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index f06849c7d1..037b67b3cb 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). @@ -726,10 +728,23 @@ pub struct Config { pub anthropic_api_version: String, /// OpenAI endpoint selection. See [`OpenAiApi`]. pub openai_api: OpenAiApi, + /// Prefer mesh-llm's virtual `mesh` model when the configured/effective + /// OpenAI model is `auto` and the live model catalog advertises it. + /// Set by Buzz's relay-mesh provider via + /// `BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1`; other providers keep their + /// existing `auto` semantics. + pub prefer_mesh_for_auto: bool, pub hints_enabled: bool, /// 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 { @@ -798,6 +813,7 @@ impl Config { base_url, anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"), openai_api, + prefer_mesh_for_auto: parse_env("BUZZ_AGENT_PREFER_MESH_FOR_AUTO", 0u8)? != 0, max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0)?, max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 32_768)?, llm_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_LLM_TIMEOUT_SECS", 240)?), @@ -824,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) @@ -844,6 +861,7 @@ impl Config { system_prompt: String::new(), anthropic_api_version: "2023-06-01".into(), openai_api: OpenAiApi::Chat, + prefer_mesh_for_auto: false, max_rounds: 0, max_output_tokens: 1, llm_timeout: Duration::from_secs(30), @@ -864,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 f5faafe853..6745dd0f92 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, }), ), @@ -920,11 +940,13 @@ mod tests { let configured = "my-configured-model"; let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list. + // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list, + // plus the configured model so the picker can still represent the model + // the agent is actually running. assert_eq!( result.len(), - DATABRICKS_V2_KNOWN_MODELS.len(), - "DatabricksV2 fallback must return all known models" + DATABRICKS_V2_KNOWN_MODELS.len() + 1, + "DatabricksV2 fallback must return all known models plus the configured model" ); let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); for known_id in DATABRICKS_V2_KNOWN_MODELS { @@ -933,6 +955,10 @@ mod tests { "DatabricksV2 fallback must include known model '{known_id}'" ); } + assert!( + result_ids.contains(&configured), + "DatabricksV2 fallback must include the configured model" + ); } #[test] diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 758be4e124..22d3f8b73e 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1,8 +1,11 @@ +use std::collections::BTreeSet; 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; use crate::auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, TokenSource}; use crate::config::{ @@ -22,11 +25,59 @@ const DATABRICKS_OAUTH_SCOPES: &[&str] = &["all-apis", "offline_access"]; const MAX_LLM_RESPONSE_BYTES: usize = 16 * 1024 * 1024; const MAX_LLM_ERROR_BODY_BYTES: usize = 4 * 1024; const STALL_NOTICE_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(300); +const MESH_VIRTUAL_MODEL_ID: &str = "mesh"; +const MESH_AUTO_MODEL_ID: &str = "auto"; +const MESH_AUTO_CATALOG_TTL: std::time::Duration = std::time::Duration::from_secs(5); +const MESH_AUTO_CATALOG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +const MESH_AUTO_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); +const MESH_AUTO_ENABLE_OBSERVATIONS: u8 = 2; +const MESH_MOA_UNAVAILABLE_MESSAGE: &str = "MoA requires ≥2 models available in the mesh"; /// Parser for an OpenAI-family JSON response. Per-endpoint pair lives /// alongside its `_body` serializer. type OpenAiParse = fn(Value) -> Result; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeshCatalogObservation { + Available, + Unavailable, + Unknown, +} + +fn mesh_catalog_supports_collective(catalog: &Value) -> Option { + let models = catalog.get("data").and_then(Value::as_array)?; + let mut has_virtual_mesh = false; + let mut physical_models = BTreeSet::new(); + for id in models + .iter() + .filter_map(|model| model.get("id").and_then(Value::as_str)) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + if id == MESH_VIRTUAL_MODEL_ID { + has_virtual_mesh = true; + } else if id != MESH_AUTO_MODEL_ID { + physical_models.insert(id.replace("@main", "")); + } + } + Some(has_virtual_mesh && physical_models.len() >= 2) +} + +fn looks_like_unstructured_tool_call(text: &str) -> bool { + let text = text.trim_start().to_ascii_lowercase(); + text.starts_with("<|tool_call") + || text.starts_with(", + consecutive_available: u8, + collective_enabled: bool, + cooldown_until: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DatabricksV2Route { OpenAiResponses, @@ -41,6 +92,10 @@ pub struct Llm { /// == Auto`. Subsequent OpenAI calls then go straight to Responses /// for the lifetime of the process. auto_upgraded: AtomicBool, + /// Hysteretic view of whether mesh-llm currently advertises its virtual + /// Mixture-of-Agents model. A TTL, confirmation count, and failure cooldown + /// let long-running agents adapt without bouncing as peers briefly flap. + mesh_auto_state: Mutex, /// Bearer-token source for OpenAI-family requests. Static for OpenAI /// (the `OPENAI_COMPAT_API_KEY` env var) and Databricks-with-token /// (the `DATABRICKS_TOKEN` env var); a refreshable PKCE engine for @@ -60,6 +115,7 @@ impl Llm { Ok(Self { http, auto_upgraded: AtomicBool::new(false), + mesh_auto_state: Mutex::new(MeshAutoState::default()), auth, }) } @@ -91,24 +147,37 @@ impl Llm { parse_anthropic(v) } Provider::OpenAi | Provider::Databricks => { - self.openai_request(cfg, effective_model, |use_responses| { - // Normalize effort for model-specific availability. Startup no longer rejects - // `max` for pure OpenAI/Databricks; this per-model table is the single authority - // — it keeps `max` for gpt-5.6, clamps `max`→`xhigh` for other OpenAI-shaped - // models, and still applies corrections like none→minimal on the gpt-5 base. - let e = effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); - if use_responses { - ( - responses_body(cfg, system_prompt, history, tools, effective_model, e), - parse_responses as OpenAiParse, - ) - } else { - ( - openai_body(cfg, system_prompt, history, tools, effective_model, e), - parse_openai as OpenAiParse, - ) - } - }) + self.openai_request( + cfg, + effective_model, + !tools.is_empty(), + |use_responses, request_model| { + // Normalize effort for model-specific availability. Startup no longer rejects + // `max` for pure OpenAI/Databricks; this per-model table is the single authority + // — it keeps `max` for gpt-5.6, clamps `max`→`xhigh` for other OpenAI-shaped + // models, and still applies corrections like none→minimal on the gpt-5 base. + let e = + effort.map(|ef| normalize_effort_for_openai_route(ef, request_model)); + if use_responses { + ( + responses_body( + cfg, + system_prompt, + history, + tools, + request_model, + e, + ), + parse_responses as OpenAiParse, + ) + } else { + ( + openai_body(cfg, system_prompt, history, tools, request_model, e), + parse_openai as OpenAiParse, + ) + } + }, + ) .await } Provider::DatabricksV2 => { @@ -181,32 +250,37 @@ impl Llm { } Provider::OpenAi | Provider::Databricks => { let r = self - .openai_request(cfg, effective_model, |use_responses| { - if use_responses { - ( - json!({ - "model": effective_model, - "max_output_tokens": max_output_tokens, - "instructions": system_prompt, - "input": user_prompt, - }), - parse_responses as OpenAiParse, - ) - } else { - ( - json!({ - "model": effective_model, - "stream": false, - "max_completion_tokens": max_output_tokens, - "messages": [ - { "role": "system", "content": system_prompt }, - { "role": "user", "content": user_prompt }, - ], - }), - parse_openai as OpenAiParse, - ) - } - }) + .openai_request( + cfg, + effective_model, + false, + |use_responses, request_model| { + if use_responses { + ( + json!({ + "model": request_model, + "max_output_tokens": max_output_tokens, + "instructions": system_prompt, + "input": user_prompt, + }), + parse_responses as OpenAiParse, + ) + } else { + ( + json!({ + "model": request_model, + "stream": false, + "max_completion_tokens": max_output_tokens, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + }), + parse_openai as OpenAiParse, + ) + } + }, + ) .await?; Ok(r.text) } @@ -255,49 +329,247 @@ impl Llm { async fn post_anthropic(&self, cfg: &Config, body: &Value) -> Result { let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); - post(&self.http, &url, body, |r| { + post(&self.http, &url, body, false, |r| { r.header("x-api-key", &cfg.api_key) .header("anthropic-version", &cfg.anthropic_api_version) }) .await + .map_err(PostError::into_agent) } - /// OpenAI dispatch: resolve endpoint (pinned > sticky-upgraded > auto by - /// host), POST, and on `auto` retry once on Responses if the provider - /// asks for it. `build` is called with `use_responses` so callers - /// only construct the body actually needed. + /// OpenAI dispatch with Buzz's relay-mesh `auto` policy layered over the + /// normal endpoint selection. When enabled, a live virtual `mesh` model is + /// preferred; if the mesh contracts between discovery and inference, retry + /// the same request once through the router's ordinary `auto` model. async fn openai_request( &self, cfg: &Config, effective_model: &str, + tools_supplied: bool, mut build: F, ) -> Result where - F: FnMut(bool) -> (Value, OpenAiParse) + Send, + F: FnMut(bool, &str) -> (Value, OpenAiParse) + Send, + { + let request_model = self.resolve_openai_model(cfg, effective_model).await; + let adaptive_mesh = + effective_model == MESH_AUTO_MODEL_ID && request_model == MESH_VIRTUAL_MODEL_ID; + + let first = self + .openai_request_for_model(cfg, &request_model, &mut build) + .await; + match first { + Err(PostError::MeshFallback(detail)) if adaptive_mesh => { + self.cool_down_collective().await; + tracing::warn!( + configured_model = effective_model, + attempted_model = MESH_VIRTUAL_MODEL_ID, + fallback_model = MESH_AUTO_MODEL_ID, + provider_message = detail, + "relay-mesh auto: collective request failed; retrying once with auto" + ); + self.openai_request_for_model(cfg, MESH_AUTO_MODEL_ID, &mut build) + .await + .map_err(PostError::into_agent) + } + Ok(response) + if adaptive_mesh + && tools_supplied + && response.tool_calls.is_empty() + && looks_like_unstructured_tool_call(&response.text) => + { + self.cool_down_collective().await; + tracing::warn!( + configured_model = effective_model, + attempted_model = MESH_VIRTUAL_MODEL_ID, + fallback_model = MESH_AUTO_MODEL_ID, + "relay-mesh auto: collective response emitted unstructured tool markup; retrying once with auto" + ); + self.openai_request_for_model(cfg, MESH_AUTO_MODEL_ID, &mut build) + .await + .map_err(PostError::into_agent) + } + Ok(response) => Ok(response), + Err(error) => Err(error.into_agent()), + } + } + + async fn cool_down_collective(&self) { + let now = Instant::now(); + let mut state = self.mesh_auto_state.lock().await; + state.last_checked = Some(now); + state.consecutive_available = 0; + state.collective_enabled = false; + state.cooldown_until = Some(now + MESH_AUTO_COOLDOWN); + } + + /// Resolve the model for one OpenAI-family request. Explicit model choices + /// are never changed. Relay-mesh `auto` dynamically follows the short-lived + /// `/models` catalog so long-running agents can adopt or leave MoA without + /// being restarted. + async fn resolve_openai_model(&self, cfg: &Config, effective_model: &str) -> String { + if cfg.provider != Provider::OpenAi + || !cfg.prefer_mesh_for_auto + || effective_model != MESH_AUTO_MODEL_ID + { + return effective_model.to_string(); + } + + let mut state = self.mesh_auto_state.lock().await; + let now = Instant::now(); + if let Some(cooldown_until) = state.cooldown_until { + if now < cooldown_until { + return MESH_AUTO_MODEL_ID.to_string(); + } + state.cooldown_until = None; + } + if state + .last_checked + .is_some_and(|checked_at| checked_at.elapsed() < MESH_AUTO_CATALOG_TTL) + { + return if state.collective_enabled { + MESH_VIRTUAL_MODEL_ID.to_string() + } else { + MESH_AUTO_MODEL_ID.to_string() + }; + } + + let observation = self.observe_mesh_virtual_model(cfg).await; + let checked_at = Instant::now(); + state.last_checked = Some(checked_at); + match observation { + MeshCatalogObservation::Available => { + state.consecutive_available = state.consecutive_available.saturating_add(1); + if state.consecutive_available >= MESH_AUTO_ENABLE_OBSERVATIONS { + state.collective_enabled = true; + } + } + MeshCatalogObservation::Unavailable => { + if state.collective_enabled { + state.cooldown_until = Some(checked_at + MESH_AUTO_COOLDOWN); + } + state.consecutive_available = 0; + state.collective_enabled = false; + } + // A failed catalog check is not evidence that a previously stable + // mesh disappeared. Preserve the last confirmed state; inference + // itself remains authoritative and has the narrow 503 fallback. + MeshCatalogObservation::Unknown => {} + } + let request_model = if state.collective_enabled { + MESH_VIRTUAL_MODEL_ID + } else { + MESH_AUTO_MODEL_ID + }; + tracing::debug!( + configured_model = effective_model, + request_model, + "relay-mesh auto: resolved request model from live catalog" + ); + request_model.to_string() + } + + async fn observe_mesh_virtual_model(&self, cfg: &Config) -> MeshCatalogObservation { + let url = format!("{}/models", cfg.base_url.trim_end_matches('/')); + let bearer = match self.auth.bearer().await { + Ok(bearer) => bearer, + Err(error) => { + tracing::debug!( + %error, + "relay-mesh auto: catalog auth unavailable; preserving last confirmed route" + ); + return MeshCatalogObservation::Unknown; + } + }; + let response = match self + .http + .get(&url) + .bearer_auth(bearer) + .timeout(MESH_AUTO_CATALOG_TIMEOUT) + .send() + .await + { + Ok(response) => response, + Err(error) => { + tracing::debug!( + %error, + "relay-mesh auto: catalog probe failed; preserving last confirmed route" + ); + return MeshCatalogObservation::Unknown; + } + }; + if !response.status().is_success() { + tracing::debug!( + status = %response.status(), + "relay-mesh auto: catalog probe was not successful; preserving last confirmed route" + ); + return MeshCatalogObservation::Unknown; + } + match response.json::().await { + Ok(catalog) => { + let Some(available) = mesh_catalog_supports_collective(&catalog) else { + tracing::debug!( + "relay-mesh auto: catalog response has no data array; preserving last confirmed route" + ); + return MeshCatalogObservation::Unknown; + }; + if available { + MeshCatalogObservation::Available + } else { + MeshCatalogObservation::Unavailable + } + } + Err(error) => { + tracing::debug!( + %error, + "relay-mesh auto: invalid catalog response; preserving last confirmed route" + ); + MeshCatalogObservation::Unknown + } + } + } + + /// Dispatch one OpenAI request for an already-resolved model. Endpoint + /// selection remains unchanged: pinned > sticky-upgraded > host default, + /// with the existing one-shot Chat-to-Responses upgrade. + async fn openai_request_for_model( + &self, + cfg: &Config, + request_model: &str, + build: &mut F, + ) -> Result + where + F: FnMut(bool, &str) -> (Value, OpenAiParse) + Send, { let use_responses = self.auto_upgraded.load(Ordering::Relaxed) || matches!(cfg.openai_api, OpenAiApi::Responses) || matches!(cfg.openai_api, OpenAiApi::Auto) && is_openai_host(&cfg.base_url); if use_responses { - let (b, p) = build(true); - return p(self - .post_openai(cfg, "/responses", &b, effective_model) - .await?); + let (body, parse) = build(true, request_model); + return parse( + self.post_openai(cfg, "/responses", &body, request_model) + .await?, + ) + .map_err(PostError::from); } - let (b, p) = build(false); + let (body, parse) = build(false, request_model); match self - .post_openai(cfg, "/chat/completions", &b, effective_model) + .post_openai(cfg, "/chat/completions", &body, request_model) .await { - Ok(v) => p(v), - Err(e) if cfg.openai_api == OpenAiApi::Auto && self.try_upgrade(&e) => { - let (b, p) = build(true); - p(self - .post_openai(cfg, "/responses", &b, effective_model) - .await?) + Ok(value) => parse(value).map_err(PostError::from), + Err(PostError::Agent(error)) + if cfg.openai_api == OpenAiApi::Auto && self.try_upgrade(&error) => + { + let (body, parse) = build(true, request_model); + parse( + self.post_openai(cfg, "/responses", &body, request_model) + .await?, + ) + .map_err(PostError::from) } - Err(e) => Err(e), + Err(error) => Err(error), } } @@ -314,7 +586,8 @@ impl Llm { let (body, parse) = build(route); parse( self.post_openai(cfg, databricks_v2_path(route), &body, effective_model) - .await?, + .await + .map_err(PostError::into_agent)?, ) } @@ -329,7 +602,7 @@ impl Llm { path: &str, body: &Value, effective_model: &str, - ) -> Result { + ) -> Result { let (url, body_owned); let body_ref: &Value = match cfg.provider { Provider::Databricks => { @@ -354,13 +627,25 @@ impl Llm { // rejection can never suppress a later turn's legitimate retry. Both // statuses map to `LlmAuth` in `post`: a 403 is indistinguishable from // an expired-token 403 here, so we refresh once and let it propagate. - let mut bearer = self.auth.bearer().await?; + let mut bearer = self.auth.bearer().await.map_err(PostError::from)?; let mut refreshed = false; loop { - match post(&self.http, &url, body_ref, |r| r.bearer_auth(&bearer)).await { - Err(AgentError::LlmAuth(_)) if !refreshed => { + match post( + &self.http, + &url, + body_ref, + effective_model == MESH_VIRTUAL_MODEL_ID, + |r| r.bearer_auth(&bearer), + ) + .await + { + Err(PostError::Agent(AgentError::LlmAuth(_))) if !refreshed => { refreshed = true; - bearer = self.auth.refresh_now(&bearer).await?; + bearer = self + .auth + .refresh_now(&bearer) + .await + .map_err(PostError::from)?; } result => return result, } @@ -435,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| { @@ -442,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); @@ -460,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() @@ -511,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)); @@ -682,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 @@ -743,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") => { @@ -794,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, }) @@ -846,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")], ) } @@ -866,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(); @@ -888,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(), )?), _ => {} } @@ -899,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, }) @@ -919,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(); @@ -941,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())); } @@ -977,6 +1531,7 @@ fn make_tool_call(id: String, name: String, args: Value) -> Result(http: &Client, url: &str, body: &Value, apply: F) -> Result +/// Internal HTTP failure that preserves a mesh-specific MoA failure as a +/// typed signal. This covers both pre-inference eligibility rejection and a +/// structured `moa_failure` from workers/reducers. It is consumed inside +/// `openai_request`: an adaptive `auto` call falls back once, while an explicit +/// `mesh` call is surfaced as the ordinary LLM error callers already +/// understand. +#[derive(Debug)] +enum PostError { + Agent(AgentError), + MeshFallback(String), +} + +impl PostError { + fn into_agent(self) -> AgentError { + match self { + Self::Agent(error) => error, + Self::MeshFallback(detail) => AgentError::Llm(detail), + } + } +} + +impl From for PostError { + fn from(error: AgentError) -> Self { + Self::Agent(error) + } +} + +fn is_mesh_moa_unavailable_body(body: &str) -> bool { + serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .pointer("/error/message") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .as_deref() + == Some(MESH_MOA_UNAVAILABLE_MESSAGE) +} + +fn is_mesh_moa_failure_body(body: &str) -> bool { + serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .pointer("/error/type") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .as_deref() + == Some("moa_failure") +} + +async fn post( + http: &Client, + url: &str, + body: &Value, + detect_mesh_fallback: bool, + apply: F, +) -> Result where F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder, { - let body_bytes = - serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; + let body_bytes = serde_json::to_vec(body) + .map_err(|e| PostError::Agent(AgentError::Llm(format!("serialize: {e}"))))?; let call_start = std::time::Instant::now(); for attempt in 0..MAX_RETRIES { let resp = match apply( @@ -1077,11 +1691,11 @@ where backoff_with_jitter(attempt).await; continue; } - return Err(terminal_llm_error( + return Err(PostError::Agent(terminal_llm_error( call_start.elapsed(), attempt + 1, &format!("transport: {e}"), - )); + ))); } }; let status = resp.status(); @@ -1093,9 +1707,19 @@ where // Not a stall path: auth failures are not surfaced through // terminal_llm_error, they resolve on the next call after refresh. if status == 401 || status == 403 { - return Err(AgentError::LlmAuth(read_error_body(resp).await)); + return Err(PostError::Agent(AgentError::LlmAuth( + read_error_body(resp).await, + ))); } if status.is_server_error() || status == 429 || status.as_u16() == 499 { + let body = read_error_body(resp).await; + let mesh_fallback = detect_mesh_fallback + && (status == reqwest::StatusCode::SERVICE_UNAVAILABLE + && is_mesh_moa_unavailable_body(&body) + || status.is_server_error() && is_mesh_moa_failure_body(&body)); + if mesh_fallback { + return Err(PostError::MeshFallback(format!("{status}: {body}"))); + } if attempt + 1 < MAX_RETRIES { tracing::warn!( attempt = attempt + 1, @@ -1106,33 +1730,32 @@ where backoff_with_jitter(attempt).await; continue; } - let body = read_error_body(resp).await; - return Err(terminal_llm_error( + return Err(PostError::Agent(terminal_llm_error( call_start.elapsed(), attempt + 1, &format!("exhausted retries: {status}: {body}"), - )); + ))); } // Not a stall path: the model is misconfigured, not the transport or // upstream capacity — no retry was attempted, so cumulative duration // would be misleading. if status == 404 { - return Err(AgentError::LlmModelNotFound(format!( + return Err(PostError::Agent(AgentError::LlmModelNotFound(format!( "{status}: {}", read_error_body(resp).await - ))); + )))); } if !status.is_success() { - return Err(AgentError::Llm(format!( + return Err(PostError::Agent(AgentError::Llm(format!( "{status}: {}", read_error_body(resp).await - ))); + )))); } if let Some(len) = resp.content_length() { if len as usize > MAX_LLM_RESPONSE_BYTES { - return Err(AgentError::Llm(format!( + return Err(PostError::Agent(AgentError::Llm(format!( "response too large: {len} > {MAX_LLM_RESPONSE_BYTES}" - ))); + )))); } } let mut buf: Vec = Vec::new(); @@ -1141,23 +1764,24 @@ where match stream.chunk().await { Ok(Some(chunk)) => { if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES { - return Err(AgentError::Llm(format!( + return Err(PostError::Agent(AgentError::Llm(format!( "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes" - ))); + )))); } buf.extend_from_slice(&chunk); } Ok(None) => break, Err(e) => { - return Err(terminal_llm_error( + return Err(PostError::Agent(terminal_llm_error( call_start.elapsed(), attempt + 1, &format!("body read: {e}"), - )); + ))); } } } - return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(format!("json: {e}"))); + return serde_json::from_slice(&buf) + .map_err(|e| PostError::Agent(AgentError::Llm(format!("json: {e}")))); } unreachable!("loop always returns on its final iteration (attempt + 1 == MAX_RETRIES)"); } @@ -1249,11 +1873,603 @@ mod tests { base_url: "http://example.invalid".into(), anthropic_api_version: "2023-06-01".into(), openai_api: OpenAiApi::Chat, + prefer_mesh_for_auto: false, hints_enabled: true, thinking_effort: None, + prompt_caching: true, } } + #[derive(Debug, Clone)] + struct CapturedHttpRequest { + method: String, + path: String, + body: Option, + } + + #[derive(Debug)] + struct StubHttpResponse { + status: u16, + body: Value, + } + + impl StubHttpResponse { + fn ok(body: Value) -> Self { + Self { status: 200, body } + } + + fn error(status: u16, message: &str) -> Self { + Self { + status, + body: json!({ + "error": { + "message": message, + "type": "server_error", + "code": "service_unavailable", + } + }), + } + } + + fn moa_failure(status: u16, code: &str, message: &str) -> Self { + Self { + status, + body: json!({ + "choices": [{ + "finish_reason": "error", + "message": { "content": message, "role": "assistant" }, + }], + "error": { + "message": message, + "type": "moa_failure", + "code": code, + }, + "model": "mesh", + }), + } + } + } + + async fn spawn_sequence_stub( + responses: Vec, + ) -> (String, Arc>>) { + use std::collections::VecDeque; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}/v1", listener.local_addr().unwrap()); + let captured = Arc::new(Mutex::new(Vec::new())); + let captured_for_server = captured.clone(); + let responses = Arc::new(Mutex::new(VecDeque::from(responses))); + + tokio::spawn(async move { + loop { + let (mut socket, _) = match listener.accept().await { + Ok(connection) => connection, + Err(_) => return, + }; + let mut bytes = Vec::new(); + let mut chunk = [0u8; 4096]; + let header_end = loop { + if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + match socket.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(read) => bytes.extend_from_slice(&chunk[..read]), + } + }; + let header_text = String::from_utf8_lossy(&bytes[..header_end]).into_owned(); + let content_length = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while bytes.len() < header_end + content_length { + match socket.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(read) => bytes.extend_from_slice(&chunk[..read]), + } + } + let mut request_line = header_text.lines().next().unwrap_or_default().split(' '); + let method = request_line.next().unwrap_or_default().to_string(); + let path = request_line.next().unwrap_or_default().to_string(); + let body = if content_length == 0 { + None + } else { + serde_json::from_slice(&bytes[header_end..header_end + content_length]).ok() + }; + captured_for_server + .lock() + .await + .push(CapturedHttpRequest { method, path, body }); + + let response = responses.lock().await.pop_front().unwrap_or_else(|| { + StubHttpResponse::error(500, "stub response sequence exhausted") + }); + let status_text = match response.status { + 200 => "OK", + 500 => "Internal Server Error", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + other => panic!("unsupported stub status: {other}"), + }; + let body = response.body.to_string(); + let wire = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status, + status_text, + body.len(), + body, + ); + let _ = socket.write_all(wire.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + (base_url, captured) + } + + fn model_catalog(ids: &[&str]) -> Value { + json!({ + "object": "list", + "data": ids + .iter() + .map(|id| json!({ "id": id, "object": "model" })) + .collect::>(), + }) + } + + fn chat_response(text: &str) -> Value { + json!({ + "choices": [{ + "finish_reason": "stop", + "message": { "content": text }, + }] + }) + } + + async fn complete_model( + llm: &Llm, + cfg: &Config, + model: &str, + ) -> Result { + llm.complete( + cfg, + "system", + &[HistoryItem::User("hello".into())], + &[], + model, + ) + .await + } + + async fn complete_model_with_tool( + llm: &Llm, + cfg: &Config, + model: &str, + ) -> Result { + llm.complete( + cfg, + "system", + &[HistoryItem::User("add two numbers".into())], + &[ToolDef { + name: "add_numbers".into(), + description: "Add two numbers".into(), + input_schema: json!({ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + }), + }], + model, + ) + .await + } + + async fn expire_mesh_catalog_check(llm: &Llm) { + llm.mesh_auto_state.lock().await.last_checked = + Some(Instant::now() - MESH_AUTO_CATALOG_TTL); + } + + async fn expire_mesh_auto_cooldown(llm: &Llm) { + let mut state = llm.mesh_auto_state.lock().await; + state.last_checked = Some(Instant::now() - MESH_AUTO_CATALOG_TTL); + state.cooldown_until = Some(Instant::now() - std::time::Duration::from_secs(1)); + } + + fn posted_models(requests: &[CapturedHttpRequest]) -> Vec<&str> { + requests + .iter() + .filter(|request| request.method == "POST") + .filter_map(|request| request.body.as_ref()?.get("model")?.as_str()) + .collect() + } + + #[tokio::test] + async fn mesh_auto_requires_two_stable_catalog_observations() { + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("direct")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("collective")), + ]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + assert_eq!( + complete_model(&llm, &config, "auto").await.unwrap().text, + "direct" + ); + expire_mesh_catalog_check(&llm).await; + assert_eq!( + complete_model(&llm, &config, "auto").await.unwrap().text, + "collective" + ); + + let requests = captured.lock().await; + assert_eq!(posted_models(&requests), vec!["auto", "mesh"]); + assert_eq!( + requests + .iter() + .filter(|request| request.path == "/v1/models") + .count(), + 2 + ); + } + + #[tokio::test] + async fn mesh_auto_does_not_enable_while_second_model_flaps() { + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("one")), + StubHttpResponse::ok(model_catalog(&["model-a"])), + StubHttpResponse::ok(chat_response("two")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("three")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("four")), + ]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + for _ in 0..4 { + complete_model(&llm, &config, "auto").await.unwrap(); + expire_mesh_catalog_check(&llm).await; + } + + let requests = captured.lock().await; + assert_eq!( + posted_models(&requests), + vec!["auto", "auto", "auto", "mesh"] + ); + } + + #[test] + fn collective_catalog_requires_two_distinct_physical_models() { + assert_eq!(mesh_catalog_supports_collective(&json!({})), None); + assert_eq!( + mesh_catalog_supports_collective(&model_catalog(&[])), + Some(false) + ); + assert_eq!( + mesh_catalog_supports_collective(&model_catalog(&["model-a", "mesh"])), + Some(false) + ); + assert_eq!( + mesh_catalog_supports_collective(&model_catalog(&[ + "org/model@main:Q4", + "org/model:Q4", + "mesh" + ])), + Some(false), + "two spellings of one model are not collective capacity" + ); + assert_eq!( + mesh_catalog_supports_collective(&model_catalog(&["model-a", "model-b", "mesh"])), + Some(true) + ); + } + + #[tokio::test] + async fn mesh_auto_tracks_models_appearing_disappearing_and_reappearing() { + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(model_catalog(&[])), + StubHttpResponse::ok(chat_response("zero")), + StubHttpResponse::ok(model_catalog(&["model-a", "mesh"])), + StubHttpResponse::ok(chat_response("one")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("two-first-observation")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("two-stable")), + StubHttpResponse::ok(model_catalog(&["model-a", "mesh"])), + StubHttpResponse::ok(chat_response("contracted")), + StubHttpResponse::ok(model_catalog(&[])), + StubHttpResponse::ok(chat_response("empty-again")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("rejoin-first-observation")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("rejoined-stable")), + ]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + for call in 0..8 { + if call == 5 { + expire_mesh_auto_cooldown(&llm).await; + } else if call > 0 { + expire_mesh_catalog_check(&llm).await; + } + complete_model(&llm, &config, "auto").await.unwrap(); + } + + let requests = captured.lock().await; + assert_eq!( + posted_models(&requests), + vec!["auto", "auto", "auto", "mesh", "auto", "auto", "auto", "mesh"] + ); + assert_eq!( + requests + .iter() + .filter(|request| request.path == "/v1/models") + .count(), + 8 + ); + } + + #[tokio::test] + async fn mesh_auto_falls_back_once_and_cools_down_when_mesh_contracts() { + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("warmup")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::error(503, MESH_MOA_UNAVAILABLE_MESSAGE), + StubHttpResponse::ok(chat_response("fallback")), + StubHttpResponse::ok(chat_response("cooldown")), + ]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + complete_model(&llm, &config, "auto").await.unwrap(); + expire_mesh_catalog_check(&llm).await; + assert_eq!( + complete_model(&llm, &config, "auto").await.unwrap().text, + "fallback" + ); + assert_eq!( + complete_model(&llm, &config, "auto").await.unwrap().text, + "cooldown" + ); + + let requests = captured.lock().await; + assert_eq!( + posted_models(&requests), + vec!["auto", "mesh", "auto", "auto"] + ); + assert_eq!( + requests + .iter() + .filter(|request| request.path == "/v1/models") + .count(), + 2, + "cooldown request must not re-probe the catalog" + ); + } + + #[tokio::test] + async fn mesh_auto_falls_back_once_when_moa_reducer_fails() { + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("warmup")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::moa_failure( + 502, + "all_reducers_failed", + "Reducer failed: unsupported chat template", + ), + StubHttpResponse::ok(chat_response("fallback")), + StubHttpResponse::ok(chat_response("cooldown")), + ]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + complete_model(&llm, &config, "auto").await.unwrap(); + expire_mesh_catalog_check(&llm).await; + assert_eq!( + complete_model(&llm, &config, "auto").await.unwrap().text, + "fallback" + ); + assert_eq!( + complete_model(&llm, &config, "auto").await.unwrap().text, + "cooldown" + ); + + let requests = captured.lock().await; + assert_eq!( + posted_models(&requests), + vec!["auto", "mesh", "auto", "auto"] + ); + assert_eq!( + requests + .iter() + .filter(|request| request.path == "/v1/models") + .count(), + 2, + "mesh-specific failure must enter cooldown without re-probing" + ); + } + + #[tokio::test] + async fn mesh_auto_retries_unstructured_tool_markup_through_auto() { + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response("warmup")), + StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), + StubHttpResponse::ok(chat_response( + "<|tool_call>call:add_numbers{a:17,b:25}", + )), + StubHttpResponse::ok(chat_response("safe fallback")), + StubHttpResponse::ok(chat_response("cooldown")), + ]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + complete_model(&llm, &config, "auto").await.unwrap(); + expire_mesh_catalog_check(&llm).await; + assert_eq!( + complete_model_with_tool(&llm, &config, "auto") + .await + .unwrap() + .text, + "safe fallback" + ); + assert_eq!( + complete_model_with_tool(&llm, &config, "auto") + .await + .unwrap() + .text, + "cooldown" + ); + + let requests = captured.lock().await; + assert_eq!( + posted_models(&requests), + vec!["auto", "mesh", "auto", "auto"] + ); + assert_eq!( + requests + .iter() + .filter(|request| request.path == "/v1/models") + .count(), + 2, + "pseudo tool markup must enter cooldown without another catalog probe" + ); + } + + #[tokio::test] + async fn mesh_auto_catalog_failure_fails_open_to_auto() { + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::error(500, "catalog unavailable"), + StubHttpResponse::ok(chat_response("direct")), + ]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + complete_model(&llm, &config, "auto").await.unwrap(); + let requests = captured.lock().await; + assert_eq!(posted_models(&requests), vec!["auto"]); + } + + #[tokio::test] + async fn generic_openai_auto_does_not_probe_or_select_mesh() { + let (base_url, captured) = + spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("provider auto"))]).await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = false; + let llm = Llm::new(&config).unwrap(); + + complete_model(&llm, &config, "auto").await.unwrap(); + let requests = captured.lock().await; + assert_eq!(posted_models(&requests), vec!["auto"]); + assert!(requests.iter().all(|request| request.path != "/v1/models")); + } + + #[tokio::test] + async fn explicit_models_are_never_rewritten_or_fallback_retried() { + let (base_url, captured) = spawn_sequence_stub(vec![StubHttpResponse::error( + 503, + MESH_MOA_UNAVAILABLE_MESSAGE, + )]) + .await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + let error = complete_model(&llm, &config, "mesh").await.unwrap_err(); + assert!(error.to_string().contains(MESH_MOA_UNAVAILABLE_MESSAGE)); + let requests = captured.lock().await; + assert_eq!(posted_models(&requests), vec!["mesh"]); + assert!(requests.iter().all(|request| request.path != "/v1/models")); + } + + #[tokio::test] + async fn explicit_real_model_bypasses_mesh_auto_policy() { + let (base_url, captured) = + spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("explicit"))]).await; + let mut config = cfg(Provider::OpenAi); + config.base_url = base_url; + config.prefer_mesh_for_auto = true; + let llm = Llm::new(&config).unwrap(); + + let response = complete_model(&llm, &config, "model-a").await.unwrap(); + assert_eq!(response.text, "explicit"); + let requests = captured.lock().await; + assert_eq!(posted_models(&requests), vec!["model-a"]); + assert!(requests.iter().all(|request| request.path != "/v1/models")); + } + + #[test] + fn mesh_unavailable_classifier_requires_exact_openai_error_message() { + assert!(is_mesh_moa_unavailable_body( + &StubHttpResponse::error(503, MESH_MOA_UNAVAILABLE_MESSAGE) + .body + .to_string() + )); + assert!(!is_mesh_moa_unavailable_body( + &StubHttpResponse::error(503, "some other outage") + .body + .to_string() + )); + } + + #[test] + fn mesh_failure_classifier_requires_structured_moa_failure_type() { + assert!(is_mesh_moa_failure_body( + &StubHttpResponse::moa_failure( + 502, + "all_reducers_failed", + "Reducer failed: unsupported chat template", + ) + .body + .to_string() + )); + assert!(!is_mesh_moa_failure_body( + &StubHttpResponse::error(502, "some other bad gateway") + .body + .to_string() + )); + } + fn image_history() -> Vec { vec![ HistoryItem::User("describe the image".into()), @@ -1263,6 +2479,7 @@ mod tests { provider_id: "toolu_1".into(), name: "dev__view_image".into(), arguments: serde_json::json!({"source":"x.png"}), + provider_extra: Default::default(), }], }, HistoryItem::ToolResult(ToolResult { @@ -1312,6 +2529,7 @@ mod tests { provider_id: "call_abc".into(), name: "dev__shell".into(), arguments: serde_json::json!({"command": "ls"}), + provider_extra: Default::default(), }], }, HistoryItem::ToolResult(ToolResult { @@ -1410,6 +2628,7 @@ mod tests { provider_id: "call_x".into(), name: "t".into(), arguments: serde_json::json!({}), + provider_extra: Default::default(), }], }, ]; @@ -1517,20 +2736,105 @@ mod tests { #[test] fn databricks_v2_routes_by_model_family() { + use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses}; for (model, route, path) in [ + // OpenAI-shaped: the gpt family plus the GPT-5 code names. ( "databricks-gpt-5-5", - DatabricksV2Route::OpenAiResponses, + OpenAiResponses, + "/ai-gateway/openai/v1/responses", + ), + ("gpt-4o", OpenAiResponses, "/ai-gateway/openai/v1/responses"), + // The intentional dashless `gpt5` spelling still routes to OpenAI. + ("gpt5", OpenAiResponses, "/ai-gateway/openai/v1/responses"), + ( + "databricks-gpt-5-6-luna", + OpenAiResponses, "/ai-gateway/openai/v1/responses", ), + ( + "databricks-gpt-5-6-sol", + OpenAiResponses, + "/ai-gateway/openai/v1/responses", + ), + ( + "databricks-terra", + OpenAiResponses, + "/ai-gateway/openai/v1/responses", + ), + // Anthropic-shaped: the claude prefix, the family names, and the + // release code names — each must reach the cache-capable route even + // when the endpoint name omits the literal "claude". ( "databricks-claude-opus-4-7", - DatabricksV2Route::AnthropicMessages, + AnthropicMessages, "/ai-gateway/anthropic/v1/messages", ), + ( + "goose-opus-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-sonnet-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-haiku-4-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-mythos-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-fable-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + // Case-insensitive. + ( + "Databricks-Claude-Opus-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + // Unrecognised names still fall through to the MLflow chat route. ( "custom-tool-model", - DatabricksV2Route::MlflowChatCompletions, + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "databricks-gemini-3-pro", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + // Collision guard: short code names must match only as whole + // segments, never as substrings of an unrelated custom alias. + // Each of these embeds a marker (`sol`, `terra`, `opus`) mid-word + // and must stay on the MLflow fallback, not adopt a wire its + // backend can't parse. + ( + "consolidated-llama", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "terraform-coder", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "corpus-reranker", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "octopus-model", + MlflowChatCompletions, "/ai-gateway/mlflow/v1/chat/completions", ), ] { @@ -1603,11 +2907,13 @@ mod tests { provider_id: "toolu_a".into(), name: "dev__view_image".into(), arguments: serde_json::json!({"source": "a.png"}), + provider_extra: Default::default(), }, ToolCall { provider_id: "toolu_b".into(), name: "dev__view_image".into(), arguments: serde_json::json!({"source": "b.png"}), + provider_extra: Default::default(), }, ], }, @@ -1659,6 +2965,100 @@ mod tests { assert_eq!(imgs[1]["image_url"]["url"], "data:image/png;base64,bbb"); } + // ---- prompt caching (cache_control) body-shape tests ---- + + #[test] + fn anthropic_body_stamps_cache_control_when_enabled() { + let body = anthropic_body( + &cfg(Provider::DatabricksV2), + "sys", + &[ + HistoryItem::User("hello".into()), + HistoryItem::Assistant { + text: "hi".into(), + tool_calls: vec![], + }, + HistoryItem::User("more".into()), + ], + &[], + "databricks-claude-opus-5", + None, + ); + // Static prefix: system promoted to a structured block carrying the marker. + assert_eq!(body["system"][0]["type"], "text"); + assert_eq!(body["system"][0]["text"], "sys"); + assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral"); + // Leapfrog: the last block of the last TWO messages is marked; earlier + // ones are not. Three distinct user/assistant/user turns → messages[1] + // and messages[2] marked, messages[0] clean. + let msgs = body["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 3); + let tail_block = |m: &Value| m["content"].as_array().unwrap().last().unwrap().clone(); + assert_eq!(tail_block(&msgs[2])["cache_control"]["type"], "ephemeral"); + assert_eq!(tail_block(&msgs[1])["cache_control"]["type"], "ephemeral"); + assert!( + tail_block(&msgs[0]).get("cache_control").is_none(), + "only the last two messages carry a breakpoint" + ); + } + + #[test] + fn anthropic_body_single_message_stamps_only_one_breakpoint() { + // With a single message there is no second turn to leapfrog to; the + // checked_sub(2) index is skipped rather than panicking. + let body = anthropic_body( + &cfg(Provider::DatabricksV2), + "sys", + &[HistoryItem::User("hello".into())], + &[], + "databricks-claude-opus-5", + None, + ); + let msgs = body["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!( + msgs[0]["content"].as_array().unwrap().last().unwrap()["cache_control"]["type"], + "ephemeral" + ); + } + + #[test] + fn anthropic_body_no_cache_control_when_disabled() { + let mut c = cfg(Provider::DatabricksV2); + c.prompt_caching = false; + let body = anthropic_body( + &c, + "sys", + &[HistoryItem::User("hello".into())], + &[], + "databricks-claude-opus-5", + None, + ); + // system stays a bare string; no marker anywhere. + assert_eq!(body["system"], "sys"); + let last_block = &body["messages"][0]["content"][0]; + assert!(last_block.get("cache_control").is_none()); + } + + #[test] + fn anthropic_body_empty_system_stays_string_even_when_caching() { + // An empty system prompt must not become an empty text block — + // Anthropic rejects those. Caching is still applied to the tail. + let body = anthropic_body( + &cfg(Provider::DatabricksV2), + "", + &[HistoryItem::User("hello".into())], + &[], + "databricks-claude-opus-5", + None, + ); + assert_eq!(body["system"], ""); + assert_eq!( + body["messages"][0]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + } + // ---- ThinkingEffort body-shape tests ---- #[test] @@ -2226,7 +3626,7 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), |b| b) + let out = post(&client, &url, &serde_json::json!({}), false, |b| b) .await .expect("post should succeed after retry"); assert_eq!(out, serde_json::json!({ "ok": true })); @@ -2290,7 +3690,7 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), |b| b) + let out = post(&client, &url, &serde_json::json!({}), false, |b| b) .await .expect("post should succeed after 499 retry"); assert_eq!(out, serde_json::json!({ "ok": true })); @@ -2341,11 +3741,11 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = post(&client, &url, &serde_json::json!({}), |b| b) + let err = post(&client, &url, &serde_json::json!({}), false, |b| b) .await .unwrap_err(); match &err { - AgentError::Llm(msg) => { + PostError::Agent(AgentError::Llm(msg)) => { assert!( msg.contains("exhausted retries") && msg.contains("499"), "expected 'exhausted retries' + '499' in error, got: {msg}" @@ -2355,7 +3755,7 @@ mod tests { "expected cumulative duration + exact attempt count, got: {msg}" ); } - other => panic!("expected AgentError::Llm, got: {other:?}"), + other => panic!("expected PostError::Agent(AgentError::Llm), got: {other:?}"), } assert_eq!( accepts.load(Ordering::SeqCst), @@ -2538,6 +3938,92 @@ mod tests { assert_eq!(parse_anthropic(v).unwrap().input_tokens, None); } + /// A Gemini reply as the Databricks MLflow route actually returns it: + /// block-array `content`, and a `thoughtSignature` beside `function`. + fn gemini_choice() -> 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!({ @@ -2548,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] @@ -2572,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!({ @@ -2693,6 +4302,7 @@ mod tests { .build() .unwrap(), auto_upgraded: std::sync::atomic::AtomicBool::new(false), + mesh_auto_state: Mutex::new(MeshAutoState::default()), auth, } } @@ -2753,7 +4363,10 @@ mod tests { .post_openai(&c, "/v1/x", &json!({}), "model") .await .unwrap_err(); - assert!(matches!(err, AgentError::LlmAuth(_)), "got {err:?}"); + assert!( + matches!(err, PostError::Agent(AgentError::LlmAuth(_))), + "got {err:?}" + ); assert_eq!( auth.refreshes.load(Ordering::SeqCst), 1, @@ -2781,7 +4394,10 @@ mod tests { .post_openai(&c, "/v1/x", &json!({}), "model") .await .unwrap_err(); - assert!(matches!(err, AgentError::LlmAuth(_)), "got {err:?}"); + assert!( + matches!(err, PostError::Agent(AgentError::LlmAuth(_))), + "got {err:?}" + ); assert_eq!( auth.refreshes.load(Ordering::SeqCst), 1, diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index fa8815df50..9ae125a0b7 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 d29e975e03..a3d48a7cf1 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 a8c668cf06..40699459fc 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 4b7257aba7..77234b7faa 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/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..40a9ae80b5 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -9,8 +9,7 @@ use crate::validate::{ validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ - extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions, - MENTION_CAP, + extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, }; /// Extract the thread root event ID from a Nostr tag array. @@ -119,47 +118,82 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result>, + has_explicit_mentions: bool, +) -> Result, CliError> { + let mut resolved = Vec::new(); + for name in names { + match name_to_pubkeys + .get(name) + .map(Vec::as_slice) + .unwrap_or_default() + { + [pubkey] => resolved.push(pubkey.clone()), + [] if has_explicit_mentions => {} + [] => { + return Err(CliError::Usage(format!( + "mention '@{name}' does not match a current channel member; retry with --mention " + ))) + } + _ if has_explicit_mentions => {} + candidates => { + return Err(CliError::Usage(format!( + "mention '@{name}' is ambiguous; candidates: {}. Retry with --mention ", + candidates.join(", ") + ))) + } + } + } + Ok(resolved) +} + +/// Resolve mention text against the channel membership snapshot. /// -/// Queries kind 39002 (channel members) then kind 0 (profiles), parses -/// display names once, and feeds them to [`extract_at_mentions_with_known`] -/// for multi-word matching. On any I/O or parse failure, returns an empty -/// vec — auto-tagging is best-effort and must never block a send. +/// Returns both the current member set and uniquely name-resolved pubkeys. +/// Lookup failures are fatal when mention processing is requested: publishing +/// visible mention text without its intended `p` tag is worse than not sending. async fn resolve_content_mentions( client: &BuzzClient, channel_id: &str, content: &str, -) -> Vec { - if !content.contains('@') { - return vec![]; + has_explicit_mentions: bool, +) -> Result<(Vec, Vec), CliError> { + let stripped = strip_code_regions(content); + if !stripped.contains('@') && !has_explicit_mentions { + return Ok((vec![], vec![])); } - // 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag). let members_filter = serde_json::json!({ "kinds": [39002], "#d": [channel_id], "limit": 1, }); - let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await { - Some(pks) if !pks.is_empty() => pks, - _ => return vec![], - }; + let member_pubkeys = fetch_member_pubkeys(client, &members_filter) + .await + .ok_or_else(|| { + CliError::Other("could not load channel membership for mention preflight".into()) + })?; + + if !stripped.contains('@') { + return Ok((member_pubkeys, vec![])); + } - // 2. Profiles for those members (kind 0). let profiles_filter = serde_json::json!({ "kinds": [0], "authors": member_pubkeys, "limit": member_pubkeys.len(), }); - let profile_events = match fetch_events(client, &profiles_filter).await { - Some(v) => v, - None => return vec![], - }; + let profile_events = fetch_events(client, &profiles_filter) + .await + .ok_or_else(|| { + CliError::Other("could not load member profiles for mention resolution".into()) + })?; - // 3. Single parse: extract (pubkey, display_name) pairs from profile JSON. let mut name_to_pubkeys: std::collections::HashMap> = std::collections::HashMap::new(); - let mut display_names: Vec = Vec::new(); + let mut display_names = Vec::new(); for e in &profile_events { let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else { continue; @@ -178,26 +212,82 @@ async fn resolve_content_mentions( else { continue; }; - let lower = name.to_ascii_lowercase(); name_to_pubkeys - .entry(lower) + .entry(name.to_ascii_lowercase()) .or_default() .push(pubkey.to_string()); display_names.push(name.to_string()); } - // 4. Two-pass extraction: known multi-word names first, single-word fallback. - let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect(); - let names = extract_at_mentions_with_known(content, &known_refs); + let known_refs: Vec<&str> = display_names.iter().map(String::as_str).collect(); + let names = extract_at_mentions_with_known(&stripped, &known_refs); + let resolved = resolve_names_to_pubkeys(&names, &name_to_pubkeys, has_explicit_mentions)?; + Ok((member_pubkeys, resolved)) +} - // 5. Look up matched names → pubkeys via the map we already built. - names +fn normalize_explicit_mentions(values: &[String]) -> Result, CliError> { + let mut normalized = Vec::new(); + for value in values { + let pubkey = PublicKey::parse(value.trim()) + .map_err(|_| CliError::Usage(format!("invalid --mention pubkey: {value}")))?; + let hex = pubkey.to_hex(); + if !normalized.contains(&hex) { + normalized.push(hex); + } + } + if normalized.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many --mention values (max {MENTION_CAP})" + ))); + } + Ok(normalized) +} + +fn merge_message_mentions( + explicit: &[String], + uri_pubkeys: &[String], + auto_resolved: &[String], +) -> Result, CliError> { + let mut mentions = Vec::new(); + for pubkey in explicit .iter() - .flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten()) + .chain(uri_pubkeys.iter()) + .chain(auto_resolved.iter()) + { + if !mentions.contains(pubkey) { + mentions.push(pubkey.clone()); + } + } + if mentions.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many unique message mentions (max {MENTION_CAP})" + ))); + } + Ok(mentions) +} + +fn missing_members(mentions: &[String], members: &[String]) -> Vec { + let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect(); + mentions + .iter() + .filter(|pk| !members.contains(pk.as_str())) .cloned() .collect() } +fn event_mention_pubkeys(event: &nostr::Event) -> Vec { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1).cloned()) + .flatten() + }) + .collect() +} + /// Fetch raw events for `filter` via the relay's `/query` endpoint. /// Returns `None` on any I/O or parse failure. async fn fetch_events( @@ -478,6 +568,7 @@ pub struct SendMessageParams { pub reply_to: Option, pub broadcast: bool, pub files: Vec, + pub mentions: Vec, } pub async fn cmd_send_message( @@ -495,6 +586,30 @@ pub async fn cmd_send_message( } let channel_uuid = parse_uuid(&p.channel_id)?; + let explicit_mentions = normalize_explicit_mentions(&p.mentions)?; + let stripped = strip_code_regions(&p.content); + let uri_pubkeys = extract_nostr_uris(&stripped); + // Supplying any identity explicitly authorizes unresolved or ambiguous @Name text + // as presentation-only, matching Desktop's separate visible-label and p-tag model. + // Uniquely resolvable member names still add their own p-tags; callers must supply + // every intended identity whose visible label cannot be resolved uniquely. + let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty(); + let (member_pubkeys, auto_resolved) = + resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?; + let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; + + let missing = missing_members(&mention_pubkeys, &member_pubkeys); + if !missing.is_empty() { + return Err(CliError::Usage( + serde_json::json!({ + "message": "mentioned pubkeys are not channel members; add them explicitly before retrying", + "missing_member_pubkeys": missing, + "add_member_command": format!("buzz channels add-member --channel {} --pubkey --role ", p.channel_id), + }) + .to_string(), + )); + } + // Upload files and build imeta tags let mut media_tags: Vec> = Vec::new(); let mut media_content = String::new(); @@ -526,16 +641,7 @@ pub async fn cmd_send_message( None }; - // Resolve @name mentions in the author-written body only — not the media markdown we - // append above, which is derived from upload metadata and can't carry `@names`. - let mut auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await; - - // NIP-27: also extract nostr:npub1… inline references (skipping code regions) - let stripped = strip_code_regions(&p.content); - let uri_pubkeys = extract_nostr_uris(&stripped); - merge_mentions(&mut auto_resolved, &uri_pubkeys, MENTION_CAP); - - let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect(); + let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); let builder = match p.kind { Some(45001) => { @@ -572,9 +678,17 @@ pub async fn cmd_send_message( }; let event = client.sign_event(builder)?; - + let emitted_mentions = event_mention_pubkeys(&event); let resp = client.submit_event(event).await?; - println!("{}", normalize_write_response(&resp)); + let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) + .unwrap_or_else(|_| serde_json::json!({ "response": resp })); + if let Some(object) = output.as_object_mut() { + object.insert( + "mention_pubkeys".into(), + serde_json::json!(emitted_mentions), + ); + } + println!("{output}"); Ok(()) } @@ -765,6 +879,7 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, } => { cmd_send_message( client, @@ -775,6 +890,7 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, }, ) .await @@ -876,7 +992,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; + use super::{ + event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, + missing_members, normalize_explicit_mentions, parse_member_pubkeys, + resolve_names_to_pubkeys, + }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -1103,6 +1223,94 @@ mod tests { assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]); } + #[test] + fn explicit_mentions_accept_hex_and_npub_and_deduplicate() { + use nostr::ToBech32; + let npub = nostr::PublicKey::from_hex(PK_VALID_A) + .unwrap() + .to_bech32() + .unwrap(); + assert_eq!( + normalize_explicit_mentions(&[PK_VALID_A.into(), npub]).unwrap(), + vec![PK_VALID_A] + ); + assert!(normalize_explicit_mentions(&["not-a-key".into()]).is_err()); + } + + #[test] + fn explicit_mentions_authorize_presentation_text_without_name_resolution() { + let names = vec!["renamed user".into()]; + let profiles = std::collections::HashMap::new(); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + Vec::::new() + ); + assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err()); + } + + #[test] + fn explicit_mentions_authorize_ambiguous_presentation_text() { + let names = vec!["alice".into()]; + let profiles = std::collections::HashMap::from([( + "alice".into(), + vec![PK_VALID_A.into(), PK_VALID_B.into()], + )]); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + Vec::::new() + ); + let error = resolve_names_to_pubkeys(&names, &profiles, false).unwrap_err(); + assert!(error.to_string().contains(PK_VALID_A)); + assert!(error.to_string().contains(PK_VALID_B)); + } + + #[test] + fn explicit_mentions_make_all_at_names_presentation_only() { + let names = vec!["alice".into(), "bob".into()]; + let profiles = std::collections::HashMap::from([("alice".into(), vec![PK_VALID_A.into()])]); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + vec![PK_VALID_A] + ); + assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err()); + } + + #[test] + fn combined_mention_union_errors_instead_of_truncating() { + let explicit: Vec = (0..50).map(|i| format!("explicit-{i}")).collect(); + assert!(merge_message_mentions(&explicit, &[], &["resolved-bob".into()]).is_err()); + + let mut with_duplicate = explicit.clone(); + with_duplicate.push(explicit[0].clone()); + assert_eq!( + merge_message_mentions(&with_duplicate, &[explicit[1].clone()], &[]) + .unwrap() + .len(), + 50 + ); + } + + #[test] + fn membership_preflight_lists_only_missing_mentions() { + assert_eq!( + missing_members( + &[PK_VALID_A.into(), PK_VALID_B.into()], + &[PK_VALID_A.into()] + ), + vec![PK_VALID_B] + ); + } + + #[test] + fn mention_evidence_comes_from_signed_event_tags() { + use nostr::{EventBuilder, Keys, Tag}; + let event = EventBuilder::text_note("hello") + .tags(vec![Tag::parse(["p", PK_VALID_A]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert_eq!(event_mention_pubkeys(&event), vec![PK_VALID_A]); + } + // ---- match_profiles_by_name (author resolution for `messages search --author`) ---- fn profile_event( diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..f5a0bee879 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 6ab81a082d..df02c65be9 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). @@ -369,6 +369,9 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, + /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. + #[arg(long = "mention")] + mentions: Vec, }, /// Send a code diff / patch to a channel SendDiff { @@ -838,6 +841,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 +1819,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 +1964,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 +2057,7 @@ mod tests { ("repos", 4), ("social", 7), ("upload", 1), - ("users", 4), + ("users", 5), ("workflows", 8), ]; @@ -2032,4 +2078,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 489df360dd..c55225adf6 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 0000000000..8f557c95aa --- /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 dd57b46937..66b7708f1d 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 23ea3c9d8f..01f1e172b6 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 2a0d276894..31efaca362 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/channel.rs b/crates/buzz-db/src/channel.rs index 396244cc96..5508c95cad 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -332,6 +332,40 @@ pub async fn set_canvas( Ok(()) } +/// Namespace for the per-channel membership advisory lock. Serializes the +/// role-authorization + last-owner-count + write sequences in [`add_member`] +/// and [`remove_member`] against each other. +/// +/// Both functions read an owner COUNT and then write a *different* row than the +/// one they counted, so `READ COMMITTED` snapshot isolation alone permits two +/// concurrent demotions (or a demotion racing a removal) to each observe two +/// owners, each pass, and together leave zero — the exact governance loss the +/// guards exist to prevent. An advisory key rather than `SELECT ... FOR UPDATE` +/// on the channel row: membership is its own contention domain and must not +/// serialize against unrelated channel metadata writers (`update_channel`, +/// `set_topic`, the TTL transition). Distinct key domain from +/// `buzz_channel_ttl:`. +const CHANNEL_MEMBERSHIP_LOCK_NAMESPACE: &str = "buzz_channel_membership:"; + +/// Take the per-channel membership lock. MUST be the first statement in the +/// transaction that then reads roles/owner counts and writes membership, so the +/// whole check-then-write sequence is atomic against a concurrent one. +async fn acquire_channel_membership_lock( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, +) -> Result<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", + community_id.as_uuid(), + channel_id + )) + .execute(&mut **tx) + .await?; + Ok(()) +} + /// Add a member to a channel. /// /// Role enforcement: @@ -360,6 +394,10 @@ pub async fn add_member( let mut tx = pool.begin().await?; + // First statement: serialize the whole role-check / owner-count / upsert + // sequence against concurrent membership writes on this channel. + acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + let channel = get_channel_tx(&mut tx, community_id, channel_id).await?; let effective_role = if channel.visibility == "private" { @@ -411,6 +449,56 @@ pub async fn add_member( } }; + // Changing an *active* member's role is privileged in BOTH directions. + // Demotion is as consequential as promotion: only owners/admins may grant + // elevated roles, so a demoted owner cannot restore themselves. Guarding + // only `role.is_elevated()` above therefore left owner→member demotion + // unauthorized-by-anyone. Re-adding an active member with the role they + // already hold stays idempotent and unguarded — the huddle bot-add and + // kind:9021 join paths rely on that. + // + // Deliberately keyed on the *active* role. A soft-removed row's stored role + // is history, not live authority: `removed_at` says it is no longer in + // force. Reactivation therefore lands at whatever `effective_role` the + // checks above already authorized — `Member` for any unprivileged caller, + // elevated only when a currently-elevated granter asked for it. Inferring + // current authority from a removed row would make soft-deleted ownership a + // resurrection token: an owner removed by another owner could self-rejoin + // via kind:9021 (`Member, None`) and silently regain ownership. + let current_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + if let Some(current_role) = current_role.filter(|r| r != effective_role.as_str()) { + let actor_role = match invited_by { + Some(inviter) => get_active_role_tx(&mut tx, community_id, channel_id, inviter).await?, + None => None, + }; + let actor_role: Option = actor_role.and_then(|r| r.parse().ok()); + if !actor_role.is_some_and(|r| r.is_elevated()) { + return Err(DbError::AccessDenied( + "only owners/admins may change an active member's role".to_string(), + )); + } + + // Defense-in-depth, mirroring `remove_member`: a demotion must not + // strip the channel of its last owner, which would leave nobody able + // to moderate, edit metadata, or re-grant ownership. + if current_role == "owner" && effective_role != MemberRole::Owner { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&mut *tx) + .await?; + let owner_count: i64 = row.try_get("cnt")?; + if owner_count <= 1 { + return Err(DbError::AccessDenied( + "cannot demote the last owner — transfer ownership first".to_string(), + )); + } + } + } + sqlx::query( r#" INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) @@ -452,10 +540,19 @@ pub async fn add_member( /// removing themselves. /// /// Returns `Err(DbError::MemberNotFound)` if the target is not an active member. -/// The actor's role check and the UPDATE run inside a transaction to prevent a -/// TOCTOU race where the actor's role changes between the check and the update. -/// The `is_agent_owner` check runs outside the transaction against the main pool -/// because `agent_owner_pubkey` is immutable (set once at token mint). +/// +/// The per-channel membership lock is the transaction's first statement, so the +/// actor's role check, the last-owner count, and the UPDATE are all serialized +/// against concurrent membership writes — otherwise a concurrent demotion of the +/// actor could commit after their role was read and this removal would proceed on +/// a stale elevated role. +/// +/// The `is_agent_owner` lookup deliberately runs *before* the transaction opens: +/// it borrows a second connection from `pool`, and issuing it while holding the +/// lock could deadlock against ourselves on a small pool. That is safe because +/// `agent_owner_pubkey` is immutable — [`crate::user::set_agent_owner`] only +/// updates it when it `IS NULL` (first-mint-wins), so its value cannot change +/// under us and needs no serialization. pub async fn remove_member( pool: &PgPool, community_id: CommunityId, @@ -463,9 +560,24 @@ pub async fn remove_member( pubkey: &[u8], actor_pubkey: &[u8], ) -> Result<()> { + let is_self_remove = pubkey == actor_pubkey; + + // Immutable, and must not be queried while holding the lock (second pool + // connection). Resolved up front so every *mutable* authorization read can + // sit behind the serialization point below. + let actor_is_agent_owner = if is_self_remove { + false + } else { + crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? + }; + let mut tx = pool.begin().await?; - let is_self_remove = pubkey == actor_pubkey; + // First statement: serialize the actor-role check, the last-owner count and + // the UPDATE against concurrent membership writes on this channel (same key + // as `add_member`). + acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + if !is_self_remove { let actor_role_str = get_active_role_tx(&mut tx, community_id, channel_id, actor_pubkey) .await? @@ -473,11 +585,7 @@ pub async fn remove_member( let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { DbError::InvalidData(format!("invalid role in database: {actor_role_str}")) })?; - // Safe to query outside the transaction: agent_owner_pubkey is immutable - // (set once at token mint, first-mint-wins). - if !actor_role.is_elevated() - && !crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? - { + if !actor_role.is_elevated() && !actor_is_agent_owner { return Err(DbError::AccessDenied( "only owners/admins or the agent's owner may remove other members".to_string(), )); @@ -1892,4 +2000,688 @@ mod tests { "random user should not be able to remove someone else's bot" ); } + + /// SECURITY REPRO (Dawn, kind:9000 demotion report): an unprivileged plain + /// member calls add_member with role=Member against the channel OWNER. + /// If this succeeds, add_member has no demotion authorization and no + /// last-owner guard. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn repro_unprivileged_member_can_demote_owner() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let victim_owner = random_pubkey(); + let attacker = random_pubkey(); + + for pk in [&victim_owner, &attacker] { + ensure_user(&pool, community, pk) + .await + .expect("ensure user"); + } + + let channel = create_test_channel( + &pool, + community_id, + "repro-demote-owner", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &victim_owner, + None, + ) + .await + .expect("create channel"); + + // create_test_channel already seeds the creator as 'owner', mirroring + // create_channel's own INSERT (channel.rs:131-145). + let role_of = |members: Vec, pk: Vec| -> Option { + members.into_iter().find(|m| m.pubkey == pk).map(|m| m.role) + }; + let before = role_of( + get_members(&pool, community, channel.id) + .await + .expect("members"), + victim_owner.clone(), + ); + assert_eq!( + before.as_deref(), + Some("owner"), + "victim must start as owner" + ); + + // Attacker: plain member, not owner/admin. + add_member( + &pool, + community, + channel.id, + &attacker, + MemberRole::Member, + None, + ) + .await + .expect("attacker self-joins open channel"); + + // The attack: attacker is `invited_by` and demotes the owner. + let res = add_member( + &pool, + community, + channel.id, + &victim_owner, + MemberRole::Member, + Some(&attacker), + ) + .await; + + let after = role_of( + get_members(&pool, community, channel.id) + .await + .expect("members"), + victim_owner.clone(), + ); + let owners = get_members(&pool, community, channel.id) + .await + .expect("members") + .into_iter() + .filter(|m| m.role == "owner") + .count(); + + assert!( + res.is_err(), + "unprivileged member must not be able to demote the owner" + ); + assert_eq!(after.as_deref(), Some("owner"), "owner role must survive"); + assert_eq!(owners, 1, "channel must still have its owner"); + } + + /// SECURITY REPRO (Dawn): same demotion on a PRIVATE channel, where the + /// attacker is a plain member. The report claims any member suffices here. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn repro_private_channel_member_can_demote_owner() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let victim_owner = random_pubkey(); + let attacker = random_pubkey(); + + for pk in [&victim_owner, &attacker] { + ensure_user(&pool, community, pk) + .await + .expect("ensure user"); + } + + let channel = create_test_channel( + &pool, + community_id, + "repro-demote-owner-private", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &victim_owner, + None, + ) + .await + .expect("create private channel"); + + // Owner invites the attacker as a plain member (legitimate). + add_member( + &pool, + community, + channel.id, + &attacker, + MemberRole::Member, + Some(&victim_owner), + ) + .await + .expect("owner invites attacker"); + + // Attack: plain member demotes the owner. + let res = add_member( + &pool, + community, + channel.id, + &victim_owner, + MemberRole::Member, + Some(&attacker), + ) + .await; + + let members = get_members(&pool, community, channel.id) + .await + .expect("members"); + let victim_role = members + .iter() + .find(|m| m.pubkey == victim_owner) + .map(|m| m.role.clone()); + let owners = members.iter().filter(|m| m.role == "owner").count(); + + assert!( + res.is_err(), + "plain member must not be able to demote the owner on a private channel" + ); + assert_eq!( + victim_role.as_deref(), + Some("owner"), + "owner role must survive" + ); + assert_eq!(owners, 1, "channel must still have its owner"); + } + + /// The fix must not break legitimate role management: an owner demoting a + /// co-owner (while another owner remains) must still succeed, and promotion + /// by an owner must still succeed. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_can_still_manage_roles_after_demotion_guard() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let other = random_pubkey(); + + for pk in [&owner, &other] { + ensure_user(&pool, community, pk) + .await + .expect("ensure user"); + } + + let channel = create_test_channel( + &pool, + community_id, + "roles-still-manageable", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + + // Owner promotes `other` to owner — allowed (actor is elevated). + add_member( + &pool, + community, + channel.id, + &other, + MemberRole::Owner, + Some(&owner), + ) + .await + .expect("owner may promote to owner"); + + // Owner demotes the co-owner back to member — allowed: actor is elevated + // and another owner remains, so the last-owner guard does not trip. + add_member( + &pool, + community, + channel.id, + &other, + MemberRole::Member, + Some(&owner), + ) + .await + .expect("owner may demote a co-owner while another owner remains"); + + let members = get_members(&pool, community, channel.id) + .await + .expect("members"); + let role_of = |pk: &Vec| { + members + .iter() + .find(|m| &m.pubkey == pk) + .map(|m| m.role.clone()) + }; + assert_eq!(role_of(&other).as_deref(), Some("member")); + assert_eq!(role_of(&owner).as_deref(), Some("owner")); + + // Idempotent re-add at the SAME role must stay unguarded even from a + // non-elevated actor — the huddle bot-add path depends on this. + let bot = random_pubkey(); + ensure_user(&pool, community, &bot) + .await + .expect("ensure bot"); + add_member( + &pool, + community, + channel.id, + &bot, + MemberRole::Bot, + Some(&owner), + ) + .await + .expect("add bot"); + add_member( + &pool, + community, + channel.id, + &bot, + MemberRole::Bot, + Some(&other), + ) + .await + .expect("re-adding at the same role must remain idempotent"); + + // But the last owner cannot be demoted, even by themselves. + let err = add_member( + &pool, + community, + channel.id, + &owner, + MemberRole::Member, + Some(&owner), + ) + .await + .expect_err("last owner must not be demotable"); + println!("last-owner demotion rejected: {err}"); + } + + /// Isolates the actor-authorization guard from the last-owner guard. + /// + /// `repro_unprivileged_member_can_demote_owner` demotes the *sole* owner, so + /// the last-owner guard alone is enough to reject it: stubbing out the actor + /// check leaves that test green and the authorization hole invisible. Here a + /// second owner remains, so the last-owner guard cannot fire and only the + /// actor check stands between an unprivileged member and a co-owner's role. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unprivileged_member_cannot_demote_a_co_owner() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let co_owner = random_pubkey(); + let attacker = random_pubkey(); + + for pk in [&owner, &co_owner, &attacker] { + ensure_user(&pool, community, pk) + .await + .expect("ensure user"); + } + + let channel = create_test_channel( + &pool, + community_id, + "co-owner-demotion-authz", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + + add_member( + &pool, + community, + channel.id, + &co_owner, + MemberRole::Owner, + Some(&owner), + ) + .await + .expect("owner may promote a co-owner"); + + add_member( + &pool, + community, + channel.id, + &attacker, + MemberRole::Member, + None, + ) + .await + .expect("attacker self-joins the open channel"); + + // Two owners remain, so the last-owner guard cannot reject this. Only + // the actor-authorization check can. + let err = add_member( + &pool, + community, + channel.id, + &co_owner, + MemberRole::Member, + Some(&attacker), + ) + .await + .expect_err("an unprivileged member must not demote a co-owner"); + println!("co-owner demotion by unprivileged actor rejected: {err}"); + + let members = get_members(&pool, community, channel.id) + .await + .expect("members"); + let role_of = |pk: &Vec| { + members + .iter() + .find(|m| &m.pubkey == pk) + .map(|m| m.role.clone()) + }; + assert_eq!( + role_of(&co_owner).as_deref(), + Some("owner"), + "co-owner must keep their role" + ); + assert_eq!( + members.iter().filter(|m| m.role == "owner").count(), + 2, + "both owners must survive" + ); + } + + /// Sets up an open channel with exactly two owners, returning + /// `(community, channel_id, owner_a, owner_b)`. + async fn channel_with_two_owners( + pool: &PgPool, + name: &str, + ) -> (CommunityId, Uuid, Vec, Vec) { + let community_id = make_test_community(pool).await; + let community = CommunityId::from_uuid(community_id); + let owner_a = random_pubkey(); + let owner_b = random_pubkey(); + for pk in [&owner_a, &owner_b] { + ensure_user(pool, community, pk).await.expect("ensure user"); + } + + let channel = create_test_channel( + pool, + community_id, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner_a, + None, + ) + .await + .expect("create channel"); + + add_member( + pool, + community, + channel.id, + &owner_b, + MemberRole::Owner, + Some(&owner_a), + ) + .await + .expect("promote second owner"); + + (community, channel.id, owner_a, owner_b) + } + + /// The lock must be shared with `remove_member`: a demotion racing an owner + /// removal goes through a separate count/update path, so both must serialize + /// on the same key or they can jointly empty the owner set. + /// + /// Deterministic rather than timing-based: an outer transaction takes the + /// per-channel membership key first, then each membership writer must block + /// until it is released. Verified by mutation — dropping the lock from either + /// function makes that call return immediately and fails this test. + #[tokio::test] + #[ignore] + async fn membership_writes_serialize_on_the_shared_channel_lock() { + let pool = setup_pool().await; + let (community, channel_id, owner_a, owner_b) = + channel_with_two_owners(&pool, "membership-lock-shared").await; + + for label in ["add_member", "remove_member"] { + // Hold the same advisory key an in-tree membership write would take. + let mut holder = pool.begin().await.expect("begin lock holder"); + acquire_channel_membership_lock(&mut holder, community, channel_id) + .await + .expect("holder acquires membership key"); + + let pool2 = pool.clone(); + let (target, actor) = (owner_a.clone(), owner_b.clone()); + let mut writer = tokio::spawn(async move { + match label { + "add_member" => add_member( + &pool2, + community, + channel_id, + &target, + MemberRole::Member, + Some(&actor), + ) + .await + .map(|_| ()), + _ => remove_member(&pool2, community, channel_id, &target, &actor).await, + } + }); + + // While the key is held, the writer must make no progress. + let blocked = + tokio::time::timeout(std::time::Duration::from_millis(750), &mut writer).await; + assert!( + blocked.is_err(), + "{label} completed while the channel membership key was held — \ + it is not serializing on the shared lock" + ); + println!("{label} blocked on the held membership key, as required"); + + // Releasing the key lets it proceed. + holder.rollback().await.expect("release membership key"); + tokio::time::timeout(std::time::Duration::from_secs(10), writer) + .await + .expect("writer must proceed once the key is released") + .expect("writer task panicked") + .expect("writer must succeed after the key is released"); + + // Restore two owners for the next iteration. + add_member( + &pool, + community, + channel_id, + &owner_a, + MemberRole::Owner, + Some(&owner_b), + ) + .await + .expect("restore second owner"); + } + } + + /// Every *mutable* authorization read must sit behind the membership lock. + /// A remover that reads its elevated role before acquiring the lock can be + /// demoted by a concurrent writer and still proceed on the stale role. + /// + /// Deterministic: the holder takes the key, `remove_member` blocks on it, the + /// holder then demotes the remover and commits. Once the key is released the + /// remover must re-read its (now unprivileged) role and be rejected. + #[tokio::test] + #[ignore] + async fn remove_member_rejects_an_actor_demoted_while_it_waited() { + let pool = setup_pool().await; + let (community, channel_id, owner_a, owner_b) = + channel_with_two_owners(&pool, "stale-actor-role").await; + // owner_b removes a plain member, so the last-owner guard is not what + // rejects this — only the actor's own role can. + let victim = random_pubkey(); + ensure_user(&pool, community, &victim) + .await + .expect("ensure victim"); + add_member( + &pool, + community, + channel_id, + &victim, + MemberRole::Member, + Some(&owner_a), + ) + .await + .expect("add victim"); + + let mut holder = pool.begin().await.expect("begin lock holder"); + acquire_channel_membership_lock(&mut holder, community, channel_id) + .await + .expect("holder acquires membership key"); + + let pool2 = pool.clone(); + let (actor, target) = (owner_b.clone(), victim.clone()); + let mut remover = tokio::spawn(async move { + remove_member(&pool2, community, channel_id, &target, &actor).await + }); + + // Must be waiting on the key, not already authorized past it. + assert!( + tokio::time::timeout(std::time::Duration::from_millis(750), &mut remover) + .await + .is_err(), + "remove_member must block on the membership key before authorizing" + ); + + // Demote the waiting actor to a plain member and release the key. + sqlx::query( + "UPDATE channel_members SET role = 'member' \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(&owner_b) + .execute(&mut *holder) + .await + .expect("demote the waiting actor"); + holder.commit().await.expect("commit demotion"); + + let result = tokio::time::timeout(std::time::Duration::from_secs(10), remover) + .await + .expect("remover must proceed once the key is released") + .expect("remover task panicked"); + + let err = result.expect_err("a demoted actor must not remove another member"); + println!("stale-role removal rejected: {err}"); + + // The victim must still be an active member. + let members = get_members(&pool, community, channel_id) + .await + .expect("members"); + assert!( + members.iter().any(|m| m.pubkey == victim), + "victim must not have been removed by a demoted actor" + ); + } + + /// A soft-removed row keeps its stored `role`, but that role is history, + /// not live authority — `removed_at` says it is no longer in force. So + /// reactivation must land at the baseline the caller was authorized for, + /// never at the role the row happens to remember. + /// + /// Regression for the sharper vulnerability the alternative would create: + /// an owner kicked by another owner self-rejoins through the kind:9021 + /// path (`Member`, no inviter) and must come back as a plain member. If + /// `add_member` inferred authority from the removed row, soft-deleted + /// ownership would be a resurrection token. + /// + /// Two owners on purpose, so the last-owner guard can never be what + /// decides the outcome — only role resolution can. + #[tokio::test] + #[ignore] + async fn kicked_owner_rejoins_as_member_not_owner() { + let pool = setup_pool().await; + let (community, channel_id, owner_a, owner_b) = + channel_with_two_owners(&pool, "kicked-owner-rejoin").await; + + // owner_a kicks owner_b (allowed: owner_a remains as the last owner). + remove_member(&pool, community, channel_id, &owner_b, &owner_a) + .await + .expect("an owner may remove another owner"); + + let stored: String = sqlx::query_scalar( + "SELECT role::text FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(&owner_b) + .fetch_one(&pool) + .await + .expect("stored role survives soft removal"); + assert_eq!( + stored, "owner", + "the removed row still remembers `owner` — which is exactly why \ + authorization must not read it" + ); + + // The kind:9021 self-rejoin path: `Member`, no inviter. + add_member( + &pool, + community, + channel_id, + &owner_b, + MemberRole::Member, + None, + ) + .await + .expect("a removed member may rejoin an open channel"); + + let rejoined = get_member_role(&pool, community, channel_id, &owner_b) + .await + .expect("read role after rejoin"); + assert_eq!( + rejoined.as_deref(), + Some("member"), + "a kicked owner must rejoin at baseline privilege, not regain ownership" + ); + } + + /// The other side of the same boundary: reactivation may reach an elevated + /// role, but only because a *currently* elevated granter asked for it. + #[tokio::test] + #[ignore] + async fn removed_owner_is_restored_only_by_a_current_owner() { + let pool = setup_pool().await; + let (community, channel_id, owner_a, owner_b) = + channel_with_two_owners(&pool, "removed-owner-restore").await; + + remove_member(&pool, community, channel_id, &owner_b, &owner_a) + .await + .expect("an owner may remove another owner"); + + // An unprivileged member cannot re-add them at `owner`. + let rando = random_pubkey(); + ensure_user(&pool, community, &rando) + .await + .expect("ensure rando"); + add_member( + &pool, + community, + channel_id, + &rando, + MemberRole::Member, + None, + ) + .await + .expect("rando self-joins open channel"); + let denied = add_member( + &pool, + community, + channel_id, + &owner_b, + MemberRole::Owner, + Some(&rando), + ) + .await; + assert!( + matches!(denied, Err(DbError::AccessDenied(_))), + "an unprivileged actor must not re-add anyone at `owner`, got {denied:?}" + ); + + // The remaining owner can. + add_member( + &pool, + community, + channel_id, + &owner_b, + MemberRole::Owner, + Some(&owner_a), + ) + .await + .expect("a current owner may restore ownership"); + let restored = get_member_role(&pool, community, channel_id, &owner_b) + .await + .expect("read role after restore"); + assert_eq!(restored.as_deref(), Some("owner")); + } } diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 7cc02d8011..511a2a6083 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -34,9 +34,10 @@ use sqlx::{PgPool, QueryBuilder}; use uuid::Uuid; use buzz_core::kind::{ - KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, - KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_WORKFLOW_APPROVAL_REQUESTED, }; use buzz_core::{CommunityId, StoredEvent}; @@ -103,7 +104,9 @@ fn build_mentions_query( qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \ - {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + {KIND_TEXT_NOTE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT}, {KIND_GIT_PULL_REQUEST}, \ + {KIND_GIT_PR_UPDATE}, {KIND_GIT_ISSUE}, {KIND_GIT_STATUS_OPEN}, \ + {KIND_GIT_STATUS_MERGED}, {KIND_GIT_STATUS_CLOSED}, {KIND_GIT_STATUS_DRAFT})" )); push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { @@ -252,7 +255,7 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -777,6 +780,12 @@ mod tests { sql.contains("AND m.community_id = "), "mentions feed must also bind event_mentions.community_id: {sql}" ); + assert!( + sql.contains(&KIND_GIT_PULL_REQUEST.to_string()) + && sql.contains(&KIND_GIT_ISSUE.to_string()) + && sql.contains(&KIND_TEXT_NOTE.to_string()), + "mentions feed must include Buzz Git roots and comments: {sql}" + ); } #[test] diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9c63b2e8ab..2a3ba9a63e 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 1674b0ec4d..1d1b7e05d4 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 0000000000..82b71b07bb --- /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 9c02f162c9..7a2396c1fd 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 36a07a18ca..cccf0e6eca 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 10a83f0324..01f78a2d49 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 f0f4c5e633..21f30065f0 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 c8ec0cdbc9..2e00d6bd2f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1872,6 +1872,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()) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 80e74e1ca0..df5bdd4c3e 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 6719e85f79..6104171cca 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`. @@ -260,9 +297,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 +320,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 +361,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 +454,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 +531,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 +545,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 +728,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/config.rs b/crates/buzz-relay/src/config.rs index 47030dcf3f..a1691349d6 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -64,6 +64,14 @@ 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, /// Public WebSocket URL of the dedicated device-pairing relay, when configured. @@ -424,6 +432,12 @@ 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()); @@ -875,6 +889,7 @@ impl Config { read_database_url, redis_url, redis_pool_size, + db_pool_size, relay_url, pairing_relay_url, max_connections, @@ -942,6 +957,7 @@ mod tests { 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 +1025,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/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index bd3eff4de9..2d82736807 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 497adc6c73..a30b0e714d 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, @@ -2542,6 +2561,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/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 840d9dcfe5..3f58a9c2aa 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 3112e9a559..65d04ef0ba 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( @@ -291,35 +337,39 @@ pub async fn validate_admin_event( match kind { 9000 => { - // Validate role tag if present - let role_str = extract_tag_value(event, "role").unwrap_or_else(|| "member".to_string()); - if role_str.parse::().is_err() { - return Err(anyhow::anyhow!("invalid role: {role_str}")); - } + // An absent role tag means "no role change requested": for an existing + // member that preserves the role they already hold, and only defaults + // to Member for a genuinely new member. Defaulting unconditionally to + // Member made a bare self-targeted PUT_USER silently demote an owner. + let role_str = extract_tag_value(event, "role"); + let requested_role = match role_str { + Some(ref s) => match s.parse::() { + Ok(r) => Some(r), + Err(_) => return Err(anyhow::anyhow!("invalid role: {s}")), + }, + None => None, + }; + + let members = state.db.get_members(tenant.community(), channel_id).await?; + let actor_role: Option = members + .iter() + .find(|m| m.pubkey == actor_bytes) + .and_then(|m| m.role.parse().ok()); // PUT_USER: open channels allow any authenticated user; private channels // require the actor to be an existing member (any role can invite). if channel.visibility == "private" { - let members = state.db.get_members(tenant.community(), channel_id).await?; - let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); - match actor_member { - Some(_) => {} - None => return Err(anyhow::anyhow!("actor not authorized")), + if actor_role.is_none() { + return Err(anyhow::anyhow!("actor not authorized")); } // Only owners/admins may grant elevated roles. - let role: buzz_db::channel::MemberRole = role_str.parse().unwrap(); - if role.is_elevated() { - let actor_role: buzz_db::channel::MemberRole = actor_member - .unwrap() - .role - .parse() - .unwrap_or(buzz_db::channel::MemberRole::Member); - if !actor_role.is_elevated() { - return Err(anyhow::anyhow!( - "only owners/admins may grant elevated roles" - )); - } + if requested_role.is_some_and(|r| r.is_elevated()) + && !actor_role.is_some_and(|r| r.is_elevated()) + { + return Err(anyhow::anyhow!( + "only owners/admins may grant elevated roles" + )); } } @@ -327,6 +377,39 @@ pub async fn validate_admin_event( let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; + // Changing an ACTIVE existing member's role is privileged in both + // directions, on every visibility. `get_members` filters + // `removed_at IS NULL`, so a soft-removed row is deliberately not an + // "existing member" here: its stored role is history, not live + // authority, and reactivation is governed by the elevated-granter + // check above rather than by the role the row remembers. + // + // `add_member` is the authority (it also covers the desktop/admin + // callers that skip this validator); rejecting here too means the + // client gets a real error instead of an OK for an event whose side + // effect then fails. Re-adding at the same role stays idempotent — + // the huddle bot-add path relies on that. + if let Some((target, role)) = members + .iter() + .find(|m| m.pubkey == target_pubkey) + .zip(requested_role) + .filter(|(m, role)| m.role != role.as_str()) + { + if !actor_role.is_some_and(|r| r.is_elevated()) { + return Err(anyhow::anyhow!( + "only owners/admins may change an active member's role" + )); + } + if target.role == "owner" + && role != buzz_db::channel::MemberRole::Owner + && members.iter().filter(|m| m.role == "owner").count() <= 1 + { + return Err(anyhow::anyhow!( + "cannot demote the last owner — transfer ownership first" + )); + } + } + // Self-add: always allowed regardless of policy. if target_pubkey == actor_bytes { return Ok(()); @@ -1208,10 +1291,22 @@ async fn handle_put_user( let channel_id = extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; - let role_str = extract_tag_value(event, "role").unwrap_or_else(|| "member".to_string()); - let role: MemberRole = role_str - .parse() - .map_err(|_| anyhow::anyhow!("invalid role: {role_str}"))?; + // No role tag = no role change: preserve an existing member's current role and + // fall back to Member only for a new member. Unconditionally defaulting to + // Member let a bare PUT_USER silently demote an existing owner/admin. + let role: MemberRole = match extract_tag_value(event, "role") { + Some(role_str) => role_str + .parse() + .map_err(|_| anyhow::anyhow!("invalid role: {role_str}"))?, + None => state + .db + .get_members(tenant.community(), channel_id) + .await? + .iter() + .find(|m| m.pubkey == target_pubkey) + .and_then(|m| m.role.parse().ok()) + .unwrap_or(MemberRole::Member), + }; let actor_bytes = event.pubkey.to_bytes().to_vec(); @@ -1292,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); @@ -1938,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 436040c32a..e271f40f0b 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 22101219eb..9e6ca828e0 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; @@ -98,6 +102,7 @@ async fn main() -> anyhow::Result<()> { // spans under the correct service identity. let resource = telemetry::service_resource(); let tracer_init = telemetry::try_init_tracer(resource.clone()); + let otel_enabled = matches!(&tracer_init, telemetry::TracerInit::Enabled(_)); let otel_layer = match &tracer_init { telemetry::TracerInit::Enabled(p) => { use opentelemetry::trace::TracerProvider as _; @@ -105,11 +110,26 @@ async fn main() -> anyhow::Result<()> { } _ => None, }; + let trace_context_lookup = telemetry::TraceContextLookup::default(); + let trace_context_lookup_layer = otel_enabled.then(|| { + trace_context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF) + }); 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() + .event_format(trace_context_lookup.json_formatter(otel_enabled)) + .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(), + )) + })) + .with(trace_context_lookup_layer) .init(); // Log any exporter-build failure now that the subscriber is installed. @@ -146,6 +166,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 +1081,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 +1493,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/router.rs b/crates/buzz-relay/src/router.rs index 2af036079f..400ed1dfe3 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; @@ -187,10 +188,23 @@ pub fn build_router(state: Arc) -> Router { merged .layer(middleware::from_fn(track_metrics)) - .layer(TraceLayer::new_for_http()) + .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } +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 +449,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 +490,60 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + #[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 68f0fea3c4..7a62188d3a 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 8dd305cd42..91bd92f0f3 100644 --- a/crates/buzz-relay/src/telemetry.rs +++ b/crates/buzz-relay/src/telemetry.rs @@ -23,8 +23,166 @@ //! - `OTEL_TRACES_SAMPLER` (default: `parentbased_always_on`) //! - `OTEL_TRACES_SAMPLER_ARG` +use std::{ + fmt, + sync::{Arc, OnceLock}, +}; + +use opentelemetry::trace::{SpanId, TraceContextExt as _, TraceId}; use opentelemetry_otlp::ExporterBuildError; use opentelemetry_sdk::{resource::EnvResourceDetector, trace::SdkTracerProvider, Resource}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::{ + fmt::{ + format::{Format, FormatEvent, FormatFields, Json, Writer}, + FmtContext, + }, + registry::LookupSpan, + EnvFilter, Layer, +}; + +/// Captures the subscriber dispatch used to resolve tracing span IDs to their +/// OpenTelemetry contexts. +#[derive(Clone, Default)] +pub struct TraceContextLookup { + dispatch: Arc>, +} + +impl TraceContextLookup { + /// Build a JSON formatter backed by this subscriber dispatch lookup. + pub fn json_formatter(&self, enabled: bool) -> TraceContextJson { + TraceContextJson { + inner: tracing_subscriber::fmt::format().json().flatten_event(true), + enabled, + context_lookup: self.clone(), + } + } + + fn nearest_otel_context(&self, span_id: &tracing::span::Id) -> Option { + let dispatch = self.dispatch.get()?.upgrade()?; + let registry = dispatch.downcast_ref::()?; + + let context = registry.span(span_id)?.scope().find_map(|span| { + let context = tracing_opentelemetry::get_otel_context(&span.id(), &dispatch)?; + context.span().span_context().is_valid().then_some(context) + }); + context + } +} + +impl Layer for TraceContextLookup { + fn on_register_dispatch(&self, subscriber: &tracing::Dispatch) { + let _ = self.dispatch.set(subscriber.downgrade()); + } +} + +/// JSON event formatter that adds the active OpenTelemetry trace context. +/// +/// Datadog recognizes the OpenTelemetry-standard `trace_id` and `span_id` +/// fields when they are lowercase hexadecimal strings. Events outside a valid +/// OpenTelemetry span retain the standard `tracing-subscriber` JSON format. +pub struct TraceContextJson { + inner: Format, + enabled: bool, + context_lookup: TraceContextLookup, +} + +struct CorrelationWriter<'writer> { + inner: Writer<'writer>, + trace_id: TraceId, + span_id: SpanId, + injected: bool, +} + +impl fmt::Write for CorrelationWriter<'_> { + fn write_str(&mut self, value: &str) -> fmt::Result { + if self.injected { + return self.inner.write_str(value); + } + + let Some(object_start) = value.find('{') else { + return self.inner.write_str(value); + }; + self.inner.write_str(&value[..=object_start])?; + write!( + self.inner, + "\"trace_id\":\"{}\",\"span_id\":\"{}\",", + self.trace_id, self.span_id + )?; + self.injected = true; + self.inner.write_str(&value[object_start + 1..]) + } +} + +impl FormatEvent for TraceContextJson +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, + N: for<'writer> FormatFields<'writer> + 'static, +{ + fn format_event( + &self, + ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + if !self.enabled { + return self.inner.format_event(ctx, writer, event); + } + + let otel_context = match event.parent() { + Some(span_id) => self.context_lookup.nearest_otel_context(span_id), + None if event.is_contextual() => Some(opentelemetry::Context::current()), + None => None, + }; + let Some(otel_context) = otel_context else { + return self.inner.format_event(ctx, writer, event); + }; + let otel_span = otel_context.span(); + let span_context = otel_span.span_context(); + + if !span_context.is_valid() { + return self.inner.format_event(ctx, writer, event); + } + + let trace_id = span_context.trace_id(); + let span_id = span_context.span_id(); + + // Events may define fields with the correlation names themselves. In + // that uncommon case, overwrite them rather than emitting duplicate + // JSON keys. Preserve the allocation-free streaming path for ordinary + // events. + let fields = event.metadata().fields(); + if fields.field("trace_id").is_some() || fields.field("span_id").is_some() { + let mut json = String::new(); + self.inner + .format_event(ctx, Writer::new(&mut json), event)?; + let mut object: serde_json::Map = + serde_json::from_str(json.trim_end()).map_err(|_| fmt::Error)?; + object.insert("trace_id".into(), trace_id.to_string().into()); + object.insert("span_id".into(), span_id.to_string().into()); + writer.write_str(&serde_json::to_string(&object).map_err(|_| fmt::Error)?)?; + return writeln!(writer); + } + + let mut writer = CorrelationWriter { + inner: writer, + trace_id, + span_id, + injected: false, + }; + self.inner + .format_event(ctx, Writer::new(&mut writer), event) + } +} + +/// 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. /// @@ -112,8 +270,13 @@ fn classify_exporter_result( #[cfg(test)] mod tests { use super::*; - use opentelemetry::KeyValue; - use std::sync::Mutex; + use opentelemetry::{trace::TracerProvider as _, KeyValue}; + use opentelemetry_sdk::trace::InMemorySpanExporter; + use std::{ + io, + sync::{Arc, Mutex}, + }; + use tracing_subscriber::prelude::*; // Env vars are process-global — serialize tests that mutate them to prevent // cross-test races when the suite runs with multiple threads. @@ -127,6 +290,209 @@ mod tests { .map(|(_, v)| v.to_string()) } + #[derive(Clone)] + struct CapturingWriter(Arc>>); + + impl io::Write for CapturingWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn trace_context_json_correlates_nested_span_logs() { + let output = Arc::new(Mutex::new(Vec::new())); + let output_writer = Arc::clone(&output); + let exporter = InMemorySpanExporter::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let tracer = provider.tracer("trace-context-json-test"); + let context_lookup = TraceContextLookup::default(); + + let subscriber = tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .json() + .event_format(context_lookup.json_formatter(true)) + .with_writer(move || CapturingWriter(Arc::clone(&output_writer))) + .with_filter(tracing_subscriber::filter::filter_fn(|metadata| { + metadata.target() != "stdout_filtered" + })), + ) + .with( + tracing_opentelemetry::layer() + .with_tracer(tracer) + .with_filter(tracing_subscriber::filter::filter_fn(|metadata| { + !matches!(metadata.target(), "filtered" | "otel_event_filtered") + })), + ) + .with( + context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF), + ); + + tracing::subscriber::with_default(subscriber, || { + let explicit = tracing::info_span!("explicit"); + let root = tracing::info_span!("root"); + root.in_scope(|| { + tracing::info!(answer = 42, "root event"); + tracing::info!( + trace_id = "event-provided-trace", + span_id = "event-provided-span", + "colliding-fields event" + ); + tracing::info!(parent: &explicit, "explicit-parent event"); + tracing::info!(parent: None, "explicit-root event"); + let child = tracing::info_span!("child"); + child.in_scope(|| tracing::info!("child event")); + + let filtered_child = tracing::info_span!(target: "filtered", "filtered-child"); + filtered_child.in_scope(|| tracing::info!("filtered-child event")); + tracing::info!( + parent: &filtered_child, + "explicit-filtered-child event" + ); + + let stdout_filtered_child = + tracing::info_span!(target: "stdout_filtered", "stdout-filtered-child"); + stdout_filtered_child.in_scope(|| tracing::info!("stdout-filtered-child event")); + + tracing::info!(target: "otel_event_filtered", "otel-filtered event"); + }); + let filtered = tracing::info_span!(target: "filtered", "filtered"); + filtered.in_scope(|| tracing::info!("filtered-span event")); + tracing::info!("unscoped event"); + }); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + let root = spans.iter().find(|span| span.name == "root").unwrap(); + let explicit = spans.iter().find(|span| span.name == "explicit").unwrap(); + let child = spans.iter().find(|span| span.name == "child").unwrap(); + let stdout_filtered_child = spans + .iter() + .find(|span| span.name == "stdout-filtered-child") + .unwrap(); + + let bytes = output.lock().unwrap().clone(); + let output = String::from_utf8(bytes).unwrap(); + let lines: Vec<&str> = output.lines().collect(); + let logs: Vec = lines + .iter() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(logs.len(), 11); + + assert_eq!(logs[0]["message"], "root event"); + assert_eq!(logs[0]["answer"], 42); + assert_eq!( + logs[0]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[0]["span_id"], root.span_context.span_id().to_string()); + assert_eq!(logs[0]["trace_id"].as_str().unwrap().len(), 32); + assert_eq!(logs[0]["span_id"].as_str().unwrap().len(), 16); + + assert_eq!(logs[1]["message"], "colliding-fields event"); + assert_eq!( + logs[1]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[1]["span_id"], root.span_context.span_id().to_string()); + assert_eq!(lines[1].matches("\"trace_id\":").count(), 1); + assert_eq!(lines[1].matches("\"span_id\":").count(), 1); + + assert_eq!(logs[2]["message"], "explicit-parent event"); + assert_eq!( + logs[2]["trace_id"], + explicit.span_context.trace_id().to_string() + ); + assert_eq!( + logs[2]["span_id"], + explicit.span_context.span_id().to_string() + ); + + assert_eq!(logs[3]["message"], "explicit-root event"); + assert!(logs[3].get("trace_id").is_none()); + assert!(logs[3].get("span_id").is_none()); + + assert_eq!(logs[4]["message"], "child event"); + assert_eq!( + logs[4]["trace_id"], + child.span_context.trace_id().to_string() + ); + assert_eq!(logs[4]["span_id"], child.span_context.span_id().to_string()); + assert_eq!(logs[0]["trace_id"], logs[4]["trace_id"]); + + assert_eq!(logs[5]["message"], "filtered-child event"); + assert_eq!( + logs[5]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[5]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[6]["message"], "explicit-filtered-child event"); + assert_eq!( + logs[6]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[6]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[7]["message"], "stdout-filtered-child event"); + assert_eq!( + logs[7]["trace_id"], + stdout_filtered_child.span_context.trace_id().to_string() + ); + assert_eq!( + logs[7]["span_id"], + stdout_filtered_child.span_context.span_id().to_string() + ); + + assert_eq!(logs[8]["message"], "otel-filtered event"); + assert_eq!( + logs[8]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[8]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[9]["message"], "filtered-span event"); + assert!(logs[9].get("trace_id").is_none()); + assert!(logs[9].get("span_id").is_none()); + + assert_eq!(logs[10]["message"], "unscoped event"); + assert!(logs[10].get("trace_id").is_none()); + assert!(logs[10].get("span_id").is_none()); + } + + #[test] + fn trace_context_lookup_does_not_enable_callsites() { + let context_lookup = TraceContextLookup::default(); + let subscriber = tracing_subscriber::registry().with( + context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF), + ); + + tracing::subscriber::with_default(subscriber, || { + assert!(context_lookup + .dispatch + .get() + .and_then(tracing::dispatcher::WeakDispatch::upgrade) + .is_some()); + assert!(!tracing::enabled!( + target: "trace_context_lookup_filter_test", + tracing::Level::ERROR + )); + }); + } + #[test] fn test_service_resource_default_when_env_unset() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..8cc9c8650a 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/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 7a7f7e19fa..6f59299ed2 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2475,3 +2475,441 @@ async fn test_reply_ingest_pushes_live_thread_summary() { client.disconnect().await.expect("disconnect"); } + +/// Read a member's authoritative role from the relay-signed kind:39002 member +/// list. The relay's own view of membership, not the client's — a kind:9000 can +/// be `accepted` (stored) while its membership side effect fails, so asserting +/// on the OK alone cannot see a broken write. +async fn member_role(url: &str, keys: &Keys, channel_id: &str, pubkey_hex: &str) -> Option { + let mut ws = BuzzTestClient::connect(url, keys).await.expect("connect"); + let sid = sub_id("members"); + let filter = Filter::new() + .kind(Kind::Custom(39002)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [channel_id]); + ws.subscribe(&sid, vec![filter]) + .await + .expect("subscribe 39002"); + let events = ws + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("39002 EOSE"); + ws.disconnect().await.ok(); + // Latest 39002 wins; find this pubkey's tag. Shape is + // ["p", pubkey, relay_url, role] (side_effects.rs:1058-1062) — role is + // index 3, not 2. + events.iter().max_by_key(|e| e.created_at).and_then(|e| { + e.tags.iter().find_map(|t| { + let p = t.as_slice(); + (p.len() >= 4 && p[0] == "p" && p[1] == pubkey_hex).then(|| p[3].clone()) + }) + }) +} + +/// SECURITY REPRO (Dawn): can an unprivileged NON-MEMBER demote the owner of an +/// OPEN channel to `member` with a single kind:9000? Asserts the reported +/// vulnerability is FIXED; it fails on vulnerable code. +#[tokio::test] +#[ignore] +async fn test_nip29_put_user_cannot_demote_owner() { + let url = relay_url(); + + let victim_keys = Keys::generate(); + let victim_hex = victim_keys.public_key().to_hex(); + let attacker_keys = Keys::generate(); + + // Victim creates an open channel -> victim is seeded as its owner. + let channel_id = create_test_channel(&victim_keys).await; + + let before = member_role(&url, &victim_keys, &channel_id, &victim_hex).await; + assert_eq!( + before.as_deref(), + Some("owner"), + "victim must start as owner (got {before:?})" + ); + + // The attack: attacker (not a member, not the creator) publishes + // kind:9000 { h=channel, p=victim, role=member }. + let mut ws = BuzzTestClient::connect(&url, &attacker_keys) + .await + .expect("connect as attacker"); + let event = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &victim_hex]).unwrap(), + Tag::parse(["role", "member"]).unwrap(), + ]) + .sign_with_keys(&attacker_keys) + .expect("sign kind 9000"); + let ok = ws.send_event(event).await.expect("send kind 9000"); + ws.disconnect().await.ok(); + + let after = member_role(&url, &victim_keys, &channel_id, &victim_hex).await; + println!( + "attacker kind:9000 accepted = {} ({})", + ok.accepted, ok.message + ); + println!("victim role before = {before:?}, after = {after:?}"); + + assert_eq!( + after.as_deref(), + Some("owner"), + "PRIVESC: unprivileged non-member demoted the channel owner to {after:?}" + ); + assert!( + !ok.accepted, + "unprivileged non-member's role-demotion kind:9000 must be rejected" + ); +} + +/// SECURITY REPRO (Dawn), follow-on questions the report asserts but does not test: +/// after the owner is demoted, (a) can the attacker promote itself to owner, and +/// (b) can the ex-owner restore its own role? Both must be rejected — which is +/// exactly what makes the demotion unrecoverable over the relay. +#[tokio::test] +#[ignore] +async fn test_nip29_owner_demotion_recovery_paths() { + let url = relay_url(); + + let victim_keys = Keys::generate(); + let victim_hex = victim_keys.public_key().to_hex(); + let attacker_keys = Keys::generate(); + let attacker_hex = attacker_keys.public_key().to_hex(); + + let channel_id = create_test_channel(&victim_keys).await; + + let put_user = |signer: Keys, target_hex: String, role: &'static str| { + let channel_id = channel_id.clone(); + let url = url.clone(); + async move { + let mut ws = BuzzTestClient::connect(&url, &signer) + .await + .expect("connect"); + // `allow_self_tagging` is REQUIRED: EventBuilder otherwise silently + // drops any `p` tag matching the signer (nostr-0.44.3 + // builder.rs:435-449), which would make self-targeted PUT_USER + // events fail as "missing p tag" and mask the real verdict. + let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &target_hex]).unwrap(), + Tag::parse(["role", role]).unwrap(), + ]) + .sign_with_keys(&signer) + .expect("sign kind 9000"); + let ok = ws.send_event(event).await.expect("send kind 9000"); + ws.disconnect().await.ok(); + ok + } + }; + + // Step 1: strip the owner. + let demote = put_user(attacker_keys.clone(), victim_hex.clone(), "member").await; + println!( + "1. attacker demotes owner -> accepted={} {}", + demote.accepted, demote.message + ); + + // Step 2: attacker tries to make itself owner. + let self_promote = put_user(attacker_keys.clone(), attacker_hex.clone(), "owner").await; + println!( + "2. attacker self->owner -> accepted={} {}", + self_promote.accepted, self_promote.message + ); + + // Step 3: ex-owner tries to restore itself. + let restore = put_user(victim_keys.clone(), victim_hex.clone(), "owner").await; + println!( + "3. ex-owner restores self -> accepted={} {}", + restore.accepted, restore.message + ); + + // `accepted` only means the event was stored — the membership side effect can + // still fail. Read the authoritative roles back from the relay-signed 39002. + let mut ws = BuzzTestClient::connect(&url, &victim_keys) + .await + .expect("connect"); + let sid = sub_id("members-final"); + let filter = Filter::new().kind(Kind::Custom(39002)).custom_tags( + SingleLetterTag::lowercase(Alphabet::D), + [channel_id.as_str()], + ); + ws.subscribe(&sid, vec![filter]) + .await + .expect("subscribe 39002"); + let events = ws + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("39002 EOSE"); + ws.disconnect().await.ok(); + let latest = events.iter().max_by_key(|e| e.created_at).expect("a 39002"); + let roles: Vec<(String, String)> = latest + .tags + .iter() + .filter_map(|t| { + let p = t.as_slice(); + (p.len() >= 4 && p[0] == "p").then(|| (p[1].clone(), p[3].clone())) + }) + .collect(); + let role_of = |hex: &str| { + roles + .iter() + .find(|(pk, _)| pk == hex) + .map(|(_, r)| r.clone()) + }; + println!("FINAL victim role = {:?}", role_of(&victim_hex)); + println!("FINAL attacker role = {:?}", role_of(&attacker_hex)); + assert_eq!( + role_of(&victim_hex).as_deref(), + Some("owner"), + "owner must retain its role through all three attempts" + ); + assert_eq!( + role_of(&attacker_hex), + None, + "attacker must never gain a role" + ); +} + +/// SECURITY REPRO (Dawn), finding #2: a kind:9000 PUT_USER with NO `role` tag. +/// +/// `handle_put_user` used to default an absent role tag to `member`, so a bare +/// self-targeted PUT_USER silently demoted the sender — no attacker required. +/// `test_nip29_put_user_self_add_bypasses_policy` sends exactly this event but +/// asserts only `ok.accepted`, and side-effect failures are logged rather than +/// surfaced (ingest.rs:2460-2467), so an `accepted` assertion structurally +/// cannot observe the demotion. This asserts the resulting STATE instead. +/// +/// TWO owners on purpose. With a sole owner the last-owner guard rejects the +/// demotion anyway, so the test would pass even with the role-preservation +/// removed — it would be measuring a different defect's fix. A second owner +/// disarms that guard, leaving the absent-role-tag handling as the only thing +/// that can decide the outcome. +#[tokio::test] +#[ignore] +async fn test_nip29_put_user_without_role_tag_preserves_role() { + let url = relay_url(); + + let owner_a = Keys::generate(); + let owner_b = Keys::generate(); + let b_hex = owner_b.public_key().to_hex(); + let channel_id = create_test_channel(&owner_a).await; + + // owner_a promotes owner_b, so the channel has two owners. + let mut ws = BuzzTestClient::connect(&url, &owner_a) + .await + .expect("connect as owner_a"); + let promote = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &b_hex]).unwrap(), + Tag::parse(["role", "owner"]).unwrap(), + ]) + .sign_with_keys(&owner_a) + .expect("sign promote"); + let ok = ws.send_event(promote).await.expect("send promote"); + ws.disconnect().await.ok(); + assert!(ok.accepted, "promote rejected: {}", ok.message); + assert_eq!( + member_role(&url, &owner_a, &channel_id, &b_hex) + .await + .as_deref(), + Some("owner"), + "owner_b must be a second owner before the probe" + ); + + // The probe: owner_b sends a bare self-targeted PUT_USER — h + p, no `role`. + // `allow_self_tagging` is required or EventBuilder drops the self `p` tag + // (nostr-0.44.3 builder.rs:435-449) and the event fails as "missing p tag". + let mut ws = BuzzTestClient::connect(&url, &owner_b) + .await + .expect("connect as owner_b"); + let bare = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &b_hex]).unwrap(), + ]) + .sign_with_keys(&owner_b) + .expect("sign bare put_user"); + let ok = ws.send_event(bare).await.expect("send bare put_user"); + ws.disconnect().await.ok(); + assert!( + ok.accepted, + "self-targeted PUT_USER must stay accepted: {}", + ok.message + ); + + assert_eq!( + member_role(&url, &owner_a, &channel_id, &b_hex) + .await + .as_deref(), + Some("owner"), + "an absent role tag means no role change — it must not demote an owner" + ); +} + +/// SECURITY (Dawn), relay-layer guard isolation — the *validator*, not the DB. +/// +/// The DB guards in `add_member` are what actually stop the privesc, and every +/// other test here asserts the resulting STATE ("the role didn't change"). +/// That makes them structurally blind to `validate_admin_event`: stubbing out +/// either relay-side check leaves the whole nip29 suite green, because the DB +/// still refuses the write and the role is still correct. Verified by mutation +/// — the relay returns `accepted:true` and logs `Side effect failed: access +/// denied: ...` while the state assertion happily passes. +/// +/// The relay guards earn their keep by giving the client an honest +/// `accepted:false` instead of an OK for an event that silently fails after the +/// fact. So these two tests assert `accepted == false` — the one observable +/// only the validator controls — and each is shaped so exactly one guard can +/// fire. +/// +/// Guard under test: "only owners/admins may change an active member's role". +/// TWO owners on purpose, so the last-owner guard cannot fire and take the +/// credit; a plain member targeting a co-owner leaves the actor check as the +/// only thing that can reject. +#[tokio::test] +#[ignore] +async fn test_nip29_relay_rejects_role_change_by_unprivileged_actor() { + let url = relay_url(); + + let owner_a = Keys::generate(); + let owner_b = Keys::generate(); + let b_hex = owner_b.public_key().to_hex(); + let attacker = Keys::generate(); + let attacker_hex = attacker.public_key().to_hex(); + let channel_id = create_test_channel(&owner_a).await; + + // owner_a promotes owner_b -> the channel has two owners. + let mut ws = BuzzTestClient::connect(&url, &owner_a) + .await + .expect("connect as owner_a"); + let promote = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &b_hex]).unwrap(), + Tag::parse(["role", "owner"]).unwrap(), + ]) + .sign_with_keys(&owner_a) + .expect("sign promote"); + let ok = ws.send_event(promote).await.expect("send promote"); + ws.disconnect().await.ok(); + assert!(ok.accepted, "promote rejected: {}", ok.message); + + // The attacker joins the open channel as a plain member. + let mut ws = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect as attacker"); + let join = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &attacker_hex]).unwrap(), + Tag::parse(["role", "member"]).unwrap(), + ]) + .sign_with_keys(&attacker) + .expect("sign self-join"); + let ok = ws.send_event(join).await.expect("send self-join"); + ws.disconnect().await.ok(); + assert!(ok.accepted, "self-join rejected: {}", ok.message); + assert_eq!( + member_role(&url, &owner_a, &channel_id, &attacker_hex) + .await + .as_deref(), + Some("member"), + "attacker must be an active plain member before the probe" + ); + + // The probe: a plain member demotes a co-owner. Two owners remain, so only + // the actor-authorization guard can reject this. + let mut ws = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect as attacker"); + let attack = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &b_hex]).unwrap(), + Tag::parse(["role", "member"]).unwrap(), + ]) + .sign_with_keys(&attacker) + .expect("sign attack"); + let ok = ws.send_event(attack).await.expect("send attack"); + ws.disconnect().await.ok(); + println!( + "unprivileged co-owner demotion -> accepted={} {}", + ok.accepted, ok.message + ); + + assert!( + !ok.accepted, + "the relay validator must reject an unprivileged actor's role change, \ + not accept it and let the side effect fail silently" + ); + assert_eq!( + member_role(&url, &owner_a, &channel_id, &b_hex) + .await + .as_deref(), + Some("owner"), + "co-owner must keep their role" + ); +} + +/// SECURITY (Dawn), relay-layer guard isolation — see the test above for why +/// `accepted` rather than state is the assertion that matters here. +/// +/// Guard under test: the relay-side last-owner check. The actor is the SOLE +/// owner demoting themselves, so the actor-authorization guard is satisfied +/// (an owner is elevated) and cannot mask the result — only the last-owner +/// check can reject. +#[tokio::test] +#[ignore] +async fn test_nip29_relay_rejects_last_owner_self_demotion() { + let url = relay_url(); + + let owner = Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let channel_id = create_test_channel(&owner).await; + + assert_eq!( + member_role(&url, &owner, &channel_id, &owner_hex) + .await + .as_deref(), + Some("owner"), + "creator must be the sole owner before the probe" + ); + + // The probe: the sole owner demotes themselves. Elevated actor, so the + // actor check passes; the last-owner guard is the only thing left. + let mut ws = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect as owner"); + let demote = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &owner_hex]).unwrap(), + Tag::parse(["role", "member"]).unwrap(), + ]) + .sign_with_keys(&owner) + .expect("sign self-demote"); + let ok = ws.send_event(demote).await.expect("send self-demote"); + ws.disconnect().await.ok(); + println!( + "sole-owner self-demotion -> accepted={} {}", + ok.accepted, ok.message + ); + + assert!( + !ok.accepted, + "the relay validator must reject demoting the last owner, not accept \ + it and let the side effect fail silently" + ); + assert_eq!( + member_role(&url, &owner, &channel_id, &owner_hex) + .await + .as_deref(), + Some("owner"), + "the last owner must keep their role" + ); +} 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 0000000000..c37be8ca40 --- /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 34e3d5311f..292f8dd027 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 93581225ee..7aaa3d1702 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 34e8bb1960..9bc79aa48b 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 956e085749..9309074895 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 4cf4b22b24..a7c4bcf63b 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 d65c1f837b..b409f4d942 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 f8d67de31d..bf2df4c2c8 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 6313fd5958..3e044f5d7c 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 203fd9b69b..53bb29bb60 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 180afc6746..8ac5086e27 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 cebe879da0..838824c17f 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -30,7 +30,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 +44,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 8c5795e359..7943b949b9 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 457e62ffc7..52d4d5c704 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", @@ -98,9 +99,12 @@ export default defineConfig({ "**/inbox-reactions.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", + "**/project-inbox.spec.ts", "**/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 +126,9 @@ export default defineConfig({ "**/observer-archive-policy.spec.ts", "**/agent-usage.spec.ts", "**/agent-usage-screenshots.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 new file mode 100644 index 0000000000..de4e003c3f --- /dev/null +++ b/desktop/public/harness-logos/CREDITS.md @@ -0,0 +1,39 @@ +# Preset harness logos — provenance + +Third-party marks bundled to identify tier-2 preset harnesses in the runtime +gallery (`PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`). +Nominative use only — each mark identifies its own vendor's harness. + +Add a row here when adding a preset logo; only bundle marks whose upstream +license permits redistribution. + +| File | Upstream | Commit | License | Source path | Modifications | +|---|---|---|---|---|---| +| `hermes.png` | [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) | `6ad632b` | MIT © 2025 Nous Research | `website/static/img/logo.png` | Cropped the baked-in border frame, padded to square, resized to 64×64, quantised to a 16-colour palette | +| `openclaw.svg` | [openclaw/openclaw](https://github.com/openclaw/openclaw) | `b06f40a` | MIT © 2026 OpenClaw Foundation | `ui/public/favicon.svg` | Removed the SMIL animation elements (renders the upstream rest pose statically — verified pixel-identical to the upstream frame at t=0); minified paths | +| `omp.svg` | [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) | `667111575ebba136dadfd6989379e7f67e0d40d9` | MIT © 2025 Mario Zechner; © 2025–2026 Can Bölük | `assets/icon.svg` | None | +| `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 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/harness-logos/amp.png b/desktop/public/harness-logos/amp.png new file mode 100644 index 0000000000..00176e85bf Binary files /dev/null and b/desktop/public/harness-logos/amp.png differ diff --git a/desktop/public/harness-logos/grok.svg b/desktop/public/harness-logos/grok.svg new file mode 100644 index 0000000000..642d7b6b0d --- /dev/null +++ b/desktop/public/harness-logos/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/desktop/public/harness-logos/hermes.png b/desktop/public/harness-logos/hermes.png new file mode 100644 index 0000000000..6acf0020b1 Binary files /dev/null and b/desktop/public/harness-logos/hermes.png differ diff --git a/desktop/public/harness-logos/kimi.png b/desktop/public/harness-logos/kimi.png new file mode 100644 index 0000000000..033c0f4fb2 Binary files /dev/null and b/desktop/public/harness-logos/kimi.png differ diff --git a/desktop/public/harness-logos/omp.svg b/desktop/public/harness-logos/omp.svg new file mode 100644 index 0000000000..f1ccf2a08a --- /dev/null +++ b/desktop/public/harness-logos/omp.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/desktop/public/harness-logos/openclaw.svg b/desktop/public/harness-logos/openclaw.svg new file mode 100644 index 0000000000..8c1496d622 --- /dev/null +++ b/desktop/public/harness-logos/openclaw.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/desktop/public/harness-logos/opencode.svg b/desktop/public/harness-logos/opencode.svg new file mode 100644 index 0000000000..157edc4d75 --- /dev/null +++ b/desktop/public/harness-logos/opencode.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/desktop/public/runtime-icons/codex.png b/desktop/public/runtime-icons/codex.png deleted file mode 100644 index fbdd19c060..0000000000 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 c5ed5e00a8..0000000000 --- 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 f34b6dc332..326587b87f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -46,520 +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. - ["src-tauri/src/commands/agents.rs", 1340], - // 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], - // harness-persona-sync: persona-runtime resolution threaded into the spawn - // path here. Load-bearing feature growth; queued to split in the resolver - // unify refactor followup. +26 for resolve_effective_prompt_model_provider - // re-introduced after 826d735fe removal (config-bridge caller still needs it). - // PGID resolution helper + PID-recycling safety guard added for orphan sweep. - // activity-feed threads avatar_url into build_managed_agent_summary for the - // assistant-bubble pinned snapshot. - // +1 for agent_pubkey field in setup payload (config-nudge card wire). - // persona-blank-fallback: resolve_effective_prompt_model_provider gains a - // record_provider param + applies persona_field_with_record_fallback. +5 lines. - // global-agent-config: spawn_agent_child loads global config and merges as - // lowest env layer (+8 lines). Queued to split. - ["src-tauri/src/managed_agents/runtime.rs", 2216], - // 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", 1765], - // 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. - ["src-tauri/src/managed_agents/runtime/tests.rs", 1041], - // 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). - ["src/shared/api/tauri.ts", 1305], - // 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. - ["src/shared/api/types.ts", 1047], - // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + - // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog - // shows required markers and credential amber rows (parity with - // CreateAgentDialog). +23 lines of gate wiring. Queued to split. - // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds - // ~16 lines. Queued to split. - // baked-env-required-badge: useBakedBuildEnvKeysQuery + bakedEnvKeys wiring - // + correct exclusion-semantics for requiredEnvKeys adds ~14 lines. - // +2 lines: filter managed provider key from requiredEnvKeys (suppress dead-input locked row). - // global-agent-config parity: wire useGlobalAgentConfig into PersonaDialog - // (Gap A: global-aware computeLocalModeGate + drop bare requiredCredentialEnvKeys; - // Gap B: hasAutoOpenedAdvancedRef auto-expand effect) + effective-provider - // save gate + Inherit/Select-a-provider label. Queued to split. - ["src/features/agents/ui/PersonaDialog.tsx", 1080], - // 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. - ["src-tauri/src/managed_agents/discovery.rs", 1393], - // 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. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1273], - // 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. - ["src-tauri/src/app_state.rs", 1081], - // 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). - ["src-tauri/src/commands/agent_models.rs", 1079], - // 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. - ["src-tauri/src/commands/agent_config.rs", 1050], - // 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. - ["src-tauri/src/commands/agent_discovery.rs", 1836], - // 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. - ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1195], - // 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/scripts/fix-appimage.sh b/desktop/scripts/fix-appimage.sh index 45f537bb72..3cd6411996 100755 --- a/desktop/scripts/fix-appimage.sh +++ b/desktop/scripts/fix-appimage.sh @@ -19,12 +19,19 @@ # skew causes eglGetDisplay to return EGL_BAD_PARAMETER under Wayland, which # WebKitWebProcess treats as fatal and aborts before the window ever appears. # -# 2. GStreamer crash: linuxdeploy also bundles libgst*.so* (GStreamer core libs). -# AppRun unconditionally sets GST_PLUGIN_SYSTEM_PATH_1_0 to a dir inside the -# AppImage that the bundler never populates (bundleMediaFramework is false by -# default), so GStreamer's plugin discovery yields an empty registry. The -# "GStreamer element appsink not found" error kills the render process; as a -# side effect the broken run poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. +# 2. GStreamer crash: linuxdeploy's compiled AppRun.wrapped force-sets +# GST_PLUGIN_SYSTEM_PATH_1_0 to $APPDIR/usr/lib/gstreamer-1.0 -- a dir the +# bundler never populates (bundleMediaFramework is off, and we strip the +# bundled libgst* core below to use the host's). Crucially, once that variable +# is set it *replaces* GStreamer's compiled-in default search path rather than +# adding to it, so the app finds ZERO plugins on every distro: +# "GStreamer element appsink not found" kills the WebKitWebProcess and the +# window never paints. An earlier revision of this script hid the failure on +# Debian only by symlinking usr/lib/gstreamer-1.0 to the Debian multiarch dir +# (/usr/lib/x86_64-linux-gnu/gstreamer-1.0); that symlink dangles on Arch and +# Fedora, and the "safe fallback to default discovery" it assumed does not +# exist -- a set GST_PLUGIN_SYSTEM_PATH_1_0 disables the default. A broken run +# also poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. # # 3. WebKit helper mismatch (latent): the bundled WebKit helpers # (WebKitNetworkProcess/WebKitWebProcess) have RUNPATH=$ORIGIN only, and @@ -35,11 +42,17 @@ # wrong -- spawning nothing, dying on unresolved bundled libs, or spawning # the system helpers -- and the window never appears. # -# Fix: remove the offending libs so the app uses the system copies (which are -# newer and ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), -# and symlink the system GStreamer plugin directory so discovery works correctly. -# No tauri.conf.json knob can do this — bundle.linux.appimage only exposes -# bundleMediaFramework, files (copy-only, no remove/symlink), and bundleXdgOpen. +# Fix: (a) remove the offending libs so the app uses the system copies (newer and +# ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), and +# (b) install a launcher shim in front of the app binary that strips the +# bundle-pointing GST_PLUGIN_* overrides AppRun.wrapped injects, letting the host +# GStreamer resolve plugins via its own default path (correct on Debian, Arch, and +# Fedora alike). The shim has to run *after* AppRun.wrapped: the wrapper rewrites +# the variable last -- after every apprun-hook -- so any value set before it is +# discarded (verified empirically; a runtime GST_PLUGIN_SYSTEM_PATH_1_0 passed +# into the AppImage does not survive). No tauri.conf.json knob can do this -- +# bundle.linux.appimage only exposes bundleMediaFramework, files (copy-only, no +# remove/symlink), and bundleXdgOpen. set -euo pipefail @@ -61,16 +74,6 @@ APPIMAGE_NAME="$(basename "$APPIMAGE_ABS")" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -# Detect multiarch triplet for GStreamer plugin path. -case "$(uname -m)" in - x86_64) MULTIARCH="x86_64-linux-gnu" ;; - aarch64) MULTIARCH="aarch64-linux-gnu" ;; - *) - echo "Error: unsupported architecture: $(uname -m)" >&2 - exit 1 - ;; -esac - WORKDIR="$(mktemp -d)" trap 'rm -rf "$WORKDIR"' EXIT @@ -88,7 +91,7 @@ if ! compgen -G "$LIBDIR/libwayland-client.so*" > /dev/null; then exit 1 fi -echo "==> Removing infra libs that conflict with system Mesa / GLib / GStreamer" +echo "==> Removing infra libs that conflict with system Mesa / GLib / GStreamer / systemd" rm -f \ "$LIBDIR"/libwayland-client.so* \ "$LIBDIR"/libwayland-cursor.so* \ @@ -101,18 +104,65 @@ rm -f \ "$LIBDIR"/libmount.so* \ "$LIBDIR"/libblkid.so* \ "$LIBDIR"/libselinux.so* \ + "$LIBDIR"/libsystemd.so* \ "$LIBDIR"/libpcre2-8.so* \ "$LIBDIR"/libgst*.so* \ "$LIBDIR"/libzstd.so* \ "$LIBDIR"/libelf.so* \ "$LIBDIR"/libffi.so* -echo "==> Symlinking system GStreamer plugin directory" -# On distros without the Debian multiarch layout (e.g. Arch), this symlink -# dangles — GStreamer then falls back to its default plugin discovery, which -# is a safe degradation (unlike the original empty in-bundle dir). -rm -rf "$LIBDIR/gstreamer-1.0" -ln -s "/usr/lib/$MULTIARCH/gstreamer-1.0" "$LIBDIR/gstreamer-1.0" +echo "==> Installing GStreamer launcher shim on the app binary" +# AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 (and the 0.10-era +# GST_PLUGIN_SYSTEM_PATH) to $APPDIR/usr/lib/gstreamer-1.0 — a dir we bundle no +# plugins into. Because a set path *replaces* GStreamer's default instead of +# extending it, the app finds zero plugins on any distro and WebKit aborts. The +# wrapper rewrites the variable after every apprun-hook, so the only place to undo +# it is a shim between AppRun.wrapped and the real binary. First confirm the +# wrapper still injects the override; if a tauri/linuxdeploy bump drops it, the +# shim becomes a harmless no-op, but we want a human to re-verify rather than +# silently ship — so fail loudly (mirrors the libwayland guard above). +APPRUN_WRAPPED="$WORKDIR/squashfs-root/AppRun.wrapped" +if ! grep -aq "GST_PLUGIN_SYSTEM_PATH_1_0" "$APPRUN_WRAPPED"; then + echo "Error: AppRun.wrapped is missing or no longer references GST_PLUGIN_SYSTEM_PATH_1_0 — GStreamer path injection changed; re-verify fix-appimage.sh" >&2 + exit 1 +fi + +APP_BIN="$WORKDIR/squashfs-root/usr/bin/buzz-desktop" +if [[ ! -f "$APP_BIN" ]]; then + echo "Error: app binary usr/bin/buzz-desktop not found — bundler layout changed; update fix-appimage.sh" >&2 + exit 1 +fi +if [[ -e "$APP_BIN.bin" ]]; then + echo "Error: usr/bin/buzz-desktop.bin already exists — shim already installed?" >&2 + exit 1 +fi + +# The real binary moves aside; buzz-desktop becomes a shim AppRun.wrapped execs. +mv "$APP_BIN" "$APP_BIN.bin" +cat > "$APP_BIN" <<'SHIM' +#!/usr/bin/env bash +# GStreamer shim installed by desktop/scripts/fix-appimage.sh. +# +# linuxdeploy's AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 to an empty +# in-bundle dir ($APPDIR/usr/lib/gstreamer-1.0). A set path *replaces* the host's +# default GStreamer search path, so the app finds zero plugins and WebKit aborts +# (blank window). Drop the bundle-pointing GST_PLUGIN_* overrides so the system +# GStreamer — which we use, having removed the bundled core libs — resolves +# plugins via its own default path on any distro. Values that don't point into +# this AppImage are the user's own and are preserved. +here="$(dirname "$(readlink -f "$0")")" +appdir="$(readlink -f "$here/../..")" +for var in GST_PLUGIN_SYSTEM_PATH_1_0 GST_PLUGIN_SYSTEM_PATH \ + GST_PLUGIN_PATH_1_0 GST_PLUGIN_PATH \ + GST_PLUGIN_SCANNER GST_PLUGIN_SCANNER_1_0; do + val="${!var-}" + if [[ -n "$val" && "$val" == *"$appdir/"* ]]; then + unset "$var" + fi +done +exec -a "buzz-desktop" "$here/buzz-desktop.bin" "$@" +SHIM +chmod +x "$APP_BIN" echo "==> Repacking AppImage" # Pass a pinned type2 runtime when provided (CI sets APPIMAGETOOL_RUNTIME_FILE); diff --git a/desktop/src-tauri/.gitignore b/desktop/src-tauri/.gitignore index 8c86ed783a..4ed297a74c 100644 --- a/desktop/src-tauri/.gitignore +++ b/desktop/src-tauri/.gitignore @@ -7,4 +7,4 @@ /gen/schemas # Sidecar binaries (built by scripts/bundle-sidecars.sh) -/binaries/ +/binaries diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 303c5eb1fa..d4f7a4a2d4 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -227,6 +227,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -990,6 +991,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "hex", "hmac 0.13.0", @@ -1008,7 +1010,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.4.26" +version = "0.5.1" dependencies = [ "anyhow", "arboard", @@ -1079,6 +1081,7 @@ dependencies = [ "tokio-tungstenite 0.29.0", "tokio-util", "toml 0.8.2", + "tracing", "url", "user-idle", "uuid", @@ -2146,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]] @@ -2406,6 +2409,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -4928,7 +4937,7 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "hex", "mesh-llm-client", @@ -4938,7 +4947,7 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4949,12 +4958,12 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" [[package]] name = "mesh-llm-client" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "async-trait", @@ -4986,7 +4995,7 @@ dependencies = [ [[package]] name = "mesh-llm-config" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "dirs", @@ -5002,7 +5011,7 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5012,7 +5021,7 @@ dependencies = [ [[package]] name = "mesh-llm-events" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "clap", @@ -5024,7 +5033,7 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "cc", @@ -5037,7 +5046,7 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "serde", "serde_json", @@ -5046,7 +5055,7 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "mesh-llm-native-runtime", ] @@ -5054,7 +5063,7 @@ dependencies = [ [[package]] name = "mesh-llm-host-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "argon2", @@ -5147,7 +5156,7 @@ dependencies = [ [[package]] name = "mesh-llm-identity" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "argon2", "base64 0.22.1", @@ -5169,7 +5178,7 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "serde", @@ -5180,7 +5189,7 @@ dependencies = [ [[package]] name = "mesh-llm-node" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "mesh-llm-types", @@ -5194,7 +5203,7 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "async-trait", @@ -5211,7 +5220,7 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "dirs", @@ -5229,7 +5238,7 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "hex", @@ -5242,7 +5251,7 @@ dependencies = [ [[package]] name = "mesh-llm-routing" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "iroh", ] @@ -5250,7 +5259,7 @@ dependencies = [ [[package]] name = "mesh-llm-runtime-install" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "dirs", @@ -5273,7 +5282,7 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5288,7 +5297,7 @@ dependencies = [ [[package]] name = "mesh-llm-skills" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "dirs", @@ -5299,7 +5308,7 @@ dependencies = [ [[package]] name = "mesh-llm-system" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "chrono", @@ -5322,7 +5331,7 @@ dependencies = [ [[package]] name = "mesh-llm-types" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "hex", "serde", @@ -5333,12 +5342,12 @@ dependencies = [ [[package]] name = "mesh-llm-ui" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" [[package]] name = "mesh-mixture-of-agents" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5411,7 +5420,7 @@ dependencies = [ [[package]] name = "model-artifact" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "async-trait", @@ -5422,7 +5431,7 @@ dependencies = [ [[package]] name = "model-hf" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "async-trait", @@ -5440,7 +5449,7 @@ dependencies = [ [[package]] name = "model-package" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "bytes", @@ -5460,7 +5469,7 @@ dependencies = [ [[package]] name = "model-ref" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "serde", ] @@ -5468,7 +5477,7 @@ dependencies = [ [[package]] name = "model-resolver" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "model-artifact", @@ -5835,6 +5844,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noq" version = "1.0.1" @@ -5899,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", @@ -6488,7 +6506,7 @@ dependencies = [ [[package]] name = "openai-frontend" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "async-trait", "axum", @@ -6674,6 +6692,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -9011,7 +9039,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "blake3", @@ -9021,7 +9049,7 @@ dependencies = [ [[package]] name = "skippy-coordinator" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "thiserror 2.0.18", ] @@ -9029,7 +9057,7 @@ dependencies = [ [[package]] name = "skippy-ffi" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "libloading 0.8.9", ] @@ -9037,12 +9065,12 @@ dependencies = [ [[package]] name = "skippy-metrics" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" [[package]] name = "skippy-protocol" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "prost", "prost-build", @@ -9053,7 +9081,7 @@ dependencies = [ [[package]] name = "skippy-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "libc", @@ -9067,7 +9095,7 @@ dependencies = [ [[package]] name = "skippy-server" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "anyhow", "async-trait", @@ -9095,7 +9123,7 @@ dependencies = [ [[package]] name = "skippy-topology" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" dependencies = [ "serde", "serde_json", @@ -10122,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", @@ -10157,7 +10185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", + "nom 7.1.3", "phf 0.11.3", "phf_codegen 0.11.3", ] @@ -10849,6 +10877,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -11403,6 +11442,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -12277,6 +12386,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wmi" version = "0.18.4" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 638f0739da..8bb643fea3 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" @@ -90,14 +90,14 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" @@ -107,7 +107,11 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4", "v5"] } png = "0.18" -arboard = "3" +# wayland-data-control: without it arboard is X11-only on Linux, so copies made +# in a Wayland session land in XWayland's clipboard where Wayland-native apps +# never see them (set_text still returns Ok). The backing wl-clipboard-rs dep is +# target-gated inside arboard; at runtime X11 sessions still fall back to X11. +arboard = { version = "3", features = ["wayland-data-control"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] } zip = "8" flate2 = "1" @@ -121,6 +125,7 @@ rubato = "3.0" audioadapter-buffers = "3.0" tempfile = "3" strip-ansi-escapes = "0.2" +tracing = "0.1" [dev-dependencies] # `test-util` enables tokio's paused-clock (`start_paused`) so the relay diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 778bd5fb3f..94d162e620 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -108,6 +108,8 @@ pub struct AppState { /// In-process mesh-llm node started by Buzz Desktop. #[cfg(feature = "mesh-llm")] pub mesh_llm_runtime: AsyncMutex>, + #[cfg(feature = "mesh-llm")] + pub mesh_recovery: crate::mesh_llm::MeshRecoveryState, /// Runtime-owned shared-compute coordinator. It publishes member-signed /// discovery status and reconciles MeshLLM's admission roster; MeshLLM /// itself owns direct QUIC/iroh connection establishment. @@ -223,6 +225,8 @@ pub fn build_app_state() -> AppState { #[cfg(feature = "mesh-llm")] mesh_llm_runtime: AsyncMutex::new(None), #[cfg(feature = "mesh-llm")] + mesh_recovery: crate::mesh_llm::MeshRecoveryState::default(), + #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), pending_owned_channels: Mutex::new(std::collections::HashSet::new()), } diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 551495d8cc..70af528156 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -494,21 +494,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 ───────────────────────────────────────────────────── @@ -527,7 +529,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, @@ -536,16 +538,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 ───────────────────────────────────────────────── @@ -559,24 +563,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 @@ -602,21 +608,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 f6094ed529..5a26f0f645 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)] @@ -39,6 +38,13 @@ pub struct RuntimeFileConfigSubset { /// Resolve the config surface with persona and global default values applied. /// +/// Linked instances are definition-authoritative: the record's own +/// system_prompt/model/provider are cleared before applying, so a stale +/// materialized snapshot can never shadow the persona's current values or a +/// blank-definition fallthrough to global defaults (mirrors +/// `effective_config::resolve_linked`). Definition-less instances keep their +/// own explicit values. +/// /// The pipeline: resolve the linked persona's prompt/model/provider, inject /// each into the record only where the record lacks its own value, let /// `read_config_surface` tag those injected fields `BuzzExplicit`, then re-tag @@ -59,6 +65,22 @@ fn resolve_config_surface( session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, ) -> RuntimeConfigSurface { + // Linked instances are definition-authoritative (mirrors + // `effective_config::resolve_linked`): the record's own + // system_prompt/model/provider fields are, at best, a stale materialized + // snapshot from the last `apply_persona_snapshot` — never a legitimate + // live override, since `update_managed_agent` blocks writing these three + // fields for linked instances. Clear them before computing `had_*` below + // so a stale byte can never masquerade as BuzzExplicit and suppress + // definition/global injection. Env var overrides (set via the advanced + // env-vars editor) are untouched — those remain a legitimate + // per-instance override regardless of link status. + if record.persona_id.is_some() { + record.system_prompt = None; + record.model = None; + record.provider = None; + } + let had_prompt = record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); let had_model = record.model.is_some(); @@ -172,22 +194,6 @@ fn resolve_config_surface( retag_global_default(&mut surface.normalized.provider); } - // Re-tag persona-snapshotted model from BuzzExplicit to PersonaDefault. - // Persona-created agents have record.model set at create time from the - // persona snapshot — had_model is true, but the model came from the persona, - // not an explicit user choice. Re-tag when the record model matches the - // persona model and no live override is active. Only applies when a persona - // is actually linked — non-persona agents with an explicit model keep BuzzExplicit. - if had_model && !model_overridden && record.persona_id.is_some() { - if let (Some(ref record_model), Some(ref persona_model_val)) = - (&record.model, &persona_model) - { - if record_model == persona_model_val { - retag_persona_default(&mut surface.normalized.model); - } - } - } - surface } @@ -678,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, @@ -702,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(), @@ -728,11 +738,64 @@ mod tests { } } - /// A model the user set explicitly in Buzz must never be re-tagged to - /// `PersonaDefault`, even when the linked persona also has a model. + /// Definition-authoritative: a stale materialized `record.model` on a + /// linked instance must never outrank (or even be consulted against) the + /// linked persona's model. `update_managed_agent` already blocks writing + /// model/provider/prompt for linked instances, so a non-`None` value here + /// can only be leftover snapshot bytes from before a persona edit — the + /// panel must report the persona's current model, tagged `PersonaDefault`, + /// not the stale byte as `BuzzExplicit`. #[test] - fn explicit_record_model_outranks_persona_and_keeps_buzz_explicit_origin() { + fn linked_stale_record_model_never_outranks_persona_model() { let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + } + + /// Definition-authoritative, blank-definition case: a linked instance + /// whose persona has no model of its own must fall through to the global + /// default, tagged `GlobalDefault` — mirroring + /// `effective_config::resolve_linked`'s `None => global` arm. A stale + /// materialized record model must not shadow this fallthrough either. + #[test] + fn linked_blank_definition_model_falls_through_to_global_default() { + let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let mut persona = persona_with_model("unused"); + persona.model = None; + let personas = vec![persona]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); + } + + /// A definition-less (no `persona_id`) instance's own explicit model IS + /// authoritative — the stale-record clearing above is scoped to linked + /// instances only. + #[test] + fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { + let mut record = agent_record(); + record.persona_id = None; record.model = Some("explicit-model".to_string()); let personas = vec![persona_with_model("persona-model")]; diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index e3d9b22189..d6429e0454 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( } #[tauri::command] -pub async fn discover_acp_providers() -> Result, String> { - tokio::task::spawn_blocking(|| { +pub async fn discover_acp_providers( + app: tauri::AppHandle, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + use tauri::Manager; crate::managed_agents::clear_resolve_cache(); crate::managed_agents::refresh_login_shell_path(); - crate::managed_agents::discover_acp_runtimes() + let custom_dir = app + .path() + .app_data_dir() + .ok() + .map(|d| d.join("custom_harnesses")); + crate::managed_agents::discover_acp_runtimes_from(custom_dir.as_deref()) }) .await .map_err(|e| format!("spawn_blocking failed: {e}")) } +/// Write a user-defined harness definition to `/custom_harnesses/.json`. +/// +/// Validates the definition (id regex, builtin-id collision, non-empty command +/// and label, env well-formedness) before touching the filesystem. Returns the +/// merged catalog entry so the UI can update the provider list without triggering +/// a full re-discover. +/// +/// `original_id` handles the rename case: when the user edits an existing +/// harness and changes its id, pass the old id here so the old file is removed +/// atomically as part of the write. If the id is unchanged or this is a new +/// harness, omit `original_id` (or pass `None`). +/// +/// The file is written using `atomic-write-file` (unique temp file + commit) +/// so concurrent saves do not race on a fixed temp path, and a partial write +/// never produces a corrupted JSON file. +#[tauri::command] +pub async fn save_custom_harness( + definition: crate::managed_agents::custom_harnesses::HarnessDefinition, + original_id: Option, + app: tauri::AppHandle, +) -> Result { + use crate::managed_agents::{ + custom_harnesses, AcpAvailabilityStatus, AuthStatus, HarnessSource, + }; + use tauri::Manager; + + // ── Phase 1: full validation before touching the filesystem ───────────── + // validate_harness_definition_pub now covers: id format, non-empty command/label, + // env key well-formedness + reserved-key check + NUL/size limits, and + // install_instructions_url scheme. + custom_harnesses::validate_harness_definition_pub(&definition)?; + custom_harnesses::check_id_collision(&definition.id)?; + + // Validate original_id BEFORE any filesystem mutation (validate-before-mutate). + let rename_old_id: Option = original_id.and_then(|oid| { + let oid = oid.trim().to_string(); + if oid.is_empty() || oid == definition.id { + None + } else { + Some(oid) + } + }); + if let Some(ref old_id) = rename_old_id { + custom_harnesses::check_id_collision(old_id) + .map_err(|_| format!("original_id {old_id:?} is a built-in and cannot be deleted"))?; + if !custom_harnesses::is_valid_harness_id_pub(old_id) { + return Err(format!("invalid original_id {old_id:?}")); + } + } + + let custom_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))? + .join("custom_harnesses"); + std::fs::create_dir_all(&custom_dir) + .map_err(|e| format!("failed to create custom_harnesses dir: {e}"))?; + + // ── Phase 2+3: backup-swap write + rename (Windows-safe, rollback on failure) + // `save_and_warm` holds the persist mutex for the write + registry-warm pair + // so concurrent saves never produce a stale registry snapshot (B-6). + custom_harnesses::save_and_warm(&custom_dir, &definition, rename_old_id.as_deref())?; + + // Resolve availability for the returned catalog entry. + let (availability, command_opt, binary_path) = + match crate::managed_agents::find_command(&definition.command) { + Some(path) => ( + AcpAvailabilityStatus::Available, + Some(definition.command.clone()), + Some(path.display().to_string()), + ), + None => (AcpAvailabilityStatus::NotInstalled, None, None), + }; + + let default_args = + crate::managed_agents::normalize_agent_args(&definition.command, definition.args.clone()); + + Ok(AcpRuntimeCatalogEntry { + id: definition.id, + label: definition.label, + // Security: no user-supplied avatar URL in catalog entries. + avatar_url: String::new(), + availability, + command: command_opt, + binary_path, + default_args, + mcp_command: None, + model_env_var: None, + provider_env_var: None, + thinking_env_var: None, + install_hint: definition.install_hint, + install_instructions_url: definition.install_instructions_url, + can_auto_install: false, + requires_external_cli: false, + underlying_cli_path: None, + node_required: false, + auth_status: AuthStatus::NotApplicable, + login_hint: None, + source: HarnessSource::Custom, + // Carry definition env back so the edit form can read and preserve it. + definition_env: definition.env, + }) +} + +/// Remove a user-defined harness definition from `/custom_harnesses/`. +/// +/// Only `source: custom` harnesses may be deleted. Attempting to delete a +/// built-in id (goose, claude, codex, buzz-agent) returns an error without +/// touching the filesystem. +#[tauri::command] +pub async fn delete_custom_harness(id: String, app: tauri::AppHandle) -> Result<(), String> { + use crate::managed_agents::custom_harnesses; + use tauri::Manager; + + // Reject built-in ids early — they have no backing file to delete and + // must never be removable from the catalog. + custom_harnesses::check_id_collision(&id) + .map_err(|_| format!("harness {id:?} is a built-in and cannot be deleted"))?; + + // Validate the id so callers cannot use path-traversal tricks. + if !custom_harnesses::is_valid_harness_id_pub(&id) { + return Err(format!("invalid harness id {id:?}")); + } + + let custom_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))? + .join("custom_harnesses"); + + // `delete_and_warm` holds the persist mutex for the delete + registry-warm + // pair so concurrent save/delete calls never produce a stale snapshot (B-6). + custom_harnesses::delete_and_warm(&custom_dir, &id)?; + + Ok(()) +} + #[tauri::command] pub async fn install_acp_runtime( runtime_id: String, @@ -547,6 +692,62 @@ fn persist_last_error_on_install( save_managed_agents(app, &records) } +/// Build the `-l -c` argument list for the install shell. +/// +/// The body runs under `pipefail`: every CLI install command is a `curl … | +/// bash` / `| sh` pipe, and without it the pipeline's status is the right-hand +/// side's — `bash`/`sh` fed an empty stdin exits 0 — so a `curl` that fails (or +/// isn't on PATH at all) was recorded as a successful `cli` step, leaving the +/// user an unactionable `verify` error instead of curl's own stderr. Every +/// install shell supports it; the Windows PowerShell path bypasses this shell. +/// `SHELLOPTS` is not exported, so the piped-to vendor script keeps its own +/// defaults. +/// +/// Off Windows, `composed_path` is passed as a positional and re-exported +/// *inside* the body, because `-l` sources the user's login startup files after +/// the process environment is installed: a profile assigning PATH overwrites +/// `cmd.env("PATH", …)` before the vendor command runs. `export PATH=` empties +/// it outright; macOS `/etc/zprofile` runs `path_helper`, which reorders it and +/// costs Buzz's managed Node/npm dirs their precedence. A positional rather than +/// an interpolated body keeps entries containing spaces or quotes intact. +/// +/// The prelude is omitted where it would do harm: +/// - `composed_path` is `None` — `export PATH="$1"` with `$1` unset sets an +/// *empty* PATH, worse than the ambient one. +/// - `is_windows` — `join_paths` uses the platform separator, so the positional +/// would be `;`-joined while bash splits PATH on `:`, collapsing every entry +/// into one nonsense path; and Windows is where the inherited fallback always +/// fires (`login_shell_path()` is unconditionally `None` there), so this would +/// be the steady state. `cmd.env("PATH", …)` already delivers the native form +/// Git Bash translates on entry, and Windows has no login startup files doing +/// the clobbering this prelude defends against. +/// +/// `is_windows` is a parameter rather than a `#[cfg]` so the Windows shape stays +/// asserted on Unix CI — the same reason `should_skip_claude_executable` takes +/// one. Extracted from `install_shell_command` for that testability, not because +/// it has more than one caller. +fn install_shell_args( + command: &str, + composed_path: Option<&std::ffi::OsStr>, + is_windows: bool, +) -> Vec { + let Some(path) = composed_path.filter(|_| !is_windows) else { + return vec![ + "-l".into(), + "-c".into(), + format!("set -o pipefail; {command}").into(), + ]; + }; + vec![ + "-l".into(), + "-c".into(), + format!("export PATH=\"$1\"; set -o pipefail; {command}").into(), + // `$0` is the shell-name slot, so the PATH must be the second positional. + "buzz-install".into(), + path.to_os_string(), + ] +} + /// Build a login-shell `Command` for `command` with hermit env vars stripped, /// Buzz-managed npm locations set, and the user's PATH set. This is the /// single source of truth for @@ -561,7 +762,6 @@ fn install_shell_command(command: &str) -> Result let shell: std::path::PathBuf = resolve_install_shell()?; let mut cmd = std::process::Command::new(&shell); - cmd.args(["-l", "-c", command]); // Strip hermit vars and set managed npm paths (see apply_npm_env). apply_npm_env(&mut cmd); @@ -569,10 +769,18 @@ fn install_shell_command(command: &str) -> Result // Compose the PATH for the install shell using the same kernel as the // runtime/probe path so the two can never drift. managed entries first // (Node/npm bins keep precedence); login-shell entries next; inherited - // process PATH appended last on Windows when no login-shell PATH exists - // (login_shell_path() always returns None on Windows — Git Bash paths are - // POSIX-shaped and poison native children; cmd.env("PATH", …) replaces - // rather than extends, so without inherited the install shell loses npm). + // process PATH appended last when no login-shell PATH exists — the case + // where the composed PATH would otherwise be Buzz's managed Node dirs + // alone, with no `curl`/`sh`/`tar` for the vendor install pipes + // (cmd.env("PATH", …) replaces rather than extends). On Windows that case + // is the steady state: login_shell_path() always returns None there + // because Git Bash paths are POSIX-shaped and poison native children. + // + // The composed PATH is set twice on purpose off Windows: `cmd.env` so the + // login startup files themselves run with a usable PATH, and the `$1` + // export in `install_shell_args` so their own PATH assignments cannot undo + // it. Neither is redundant — see `install_shell_args`, which also explains + // why the export is suppressed on Windows. let login_path = crate::managed_agents::login_shell_path(); let had_login = login_path.is_some(); let managed: Vec = [ @@ -589,14 +797,20 @@ fn install_shell_command(command: &str) -> Result let inherited: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - let use_inherited = crate::managed_agents::should_use_inherited(had_login, true, cfg!(windows)); + let use_inherited = crate::managed_agents::should_use_inherited(had_login, true); let path_parts = crate::managed_agents::compose_path_entries(managed, login, inherited, use_inherited); - if !path_parts.is_empty() { - if let Ok(path) = std::env::join_paths(path_parts) { - cmd.env("PATH", path); - } + let composed_path = (!path_parts.is_empty()) + .then(|| std::env::join_paths(path_parts).ok()) + .flatten(); + if let Some(path) = composed_path.as_deref() { + cmd.env("PATH", path); } + cmd.args(install_shell_args( + command, + composed_path.as_deref(), + cfg!(windows), + )); // Detach from the controlling terminal so install scripts that read from // /dev/tty (e.g. Codex's "Start Codex now? [y/N]") fall back to stdin @@ -648,72 +862,6 @@ pub(crate) fn install_shell_from( resolved.ok_or_else(|| crate::managed_agents::git_bash::GIT_BASH_INSTALL_HINT.to_string()) } -/// 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. -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 -} - /// 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 @@ -867,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; @@ -1175,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] @@ -1215,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)) @@ -1229,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" ); } @@ -1408,6 +1412,123 @@ mod tests { assert!(result.is_ok(), "install_shell_command must succeed on Unix"); } + // ── pipefail: install pipes must not mask a failing left-hand side ──────── + + /// The command handed to the install shell must run under `set -o pipefail;` + /// with the vendor command preserved verbatim, so `curl … | bash` fails when + /// `curl` does. Platform-agnostic: only the PATH prelude differs by OS, and + /// `test_install_shell_args_shape_per_platform` pins that. + #[test] + fn test_install_shell_command_enables_pipefail() { + let cmd = super::install_shell_command("curl -fsSL https://example.test/i.sh | bash") + .expect("install shell must resolve on a test host"); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let body = &args[2]; + assert!( + body.contains("set -o pipefail; "), + "the install body must set pipefail; got: {body}" + ); + assert!( + body.ends_with("curl -fsSL https://example.test/i.sh | bash"), + "the vendor command must be preserved verbatim; got: {body}" + ); + } + + /// The PATH prelude is emitted only where it helps, and the exact argument + /// vector is the contract: a stray trailing positional with no `$1` reader, + /// or an export whose `$1` the shell cannot split, both corrupt PATH. + /// Windows is excluded because `join_paths` is `;`-separated there while bash + /// splits PATH on `:` — and it is the platform where the inherited fallback + /// always fires. See `install_shell_args` for the full reasoning. + #[test] + fn test_install_shell_args_shape_per_platform() { + let composed = std::ffi::OsString::from("/buzz/node/bin:/usr/bin"); + let windows_composed = std::ffi::OsString::from(r"C:\buzz\node;C:\Windows\system32"); + let bare = ["-l", "-c", "set -o pipefail; echo hi"].map(std::ffi::OsString::from); + + assert_eq!( + super::install_shell_args("echo hi", Some(&composed), false), + [ + "-l", + "-c", + "export PATH=\"$1\"; set -o pipefail; echo hi", + "buzz-install", + "/buzz/node/bin:/usr/bin", + ] + .map(std::ffi::OsString::from), + "Unix must re-export the composed PATH after login init" + ); + assert_eq!( + super::install_shell_args("echo hi", Some(&windows_composed), true), + bare, + "Windows must not re-export a `;`-joined PATH inside bash" + ); + assert_eq!( + super::install_shell_args("echo hi", None, false), + bare, + "no composed PATH must yield the bare pipefail body and no positionals" + ); + } + + /// Regression for the login-startup-file overwrite: `cmd.env("PATH", …)` is + /// installed *before* `-l` sources the user's profile, so a profile that + /// assigns PATH silently discards the composed one. Uses `/bin/bash` + /// explicitly — the planted profile is bash-specific, so resolving the host + /// shell (which prefers zsh) would make this vacuous. + #[cfg(unix)] + #[test] + fn test_composed_path_survives_a_profile_that_clears_it() { + let home = tempfile::tempdir().expect("temp HOME"); + std::fs::write(home.path().join(".bash_profile"), "export PATH=\n") + .expect("plant a hostile login profile"); + let composed = std::ffi::OsString::from("/buzz/sentinel/bin:/usr/bin:/bin"); + + // `echo` is a shell builtin, so the child needs no PATH to report one. + let out = std::process::Command::new("/bin/bash") + .args(super::install_shell_args( + "echo \"$PATH\"", + Some(&composed), + false, + )) + .env("HOME", home.path()) + .env("PATH", &composed) + .stdin(std::process::Stdio::null()) + .output() + .expect("bash must spawn"); + + let path = String::from_utf8_lossy(&out.stdout); + assert!( + path.contains("/buzz/sentinel/bin"), + "the composed PATH must survive login init; got: {path:?}" + ); + } + + /// End-to-end on the real resolved install shell (no network): a pipeline + /// whose left-hand side fails must exit non-zero, while a fully successful + /// pipeline must still succeed. Without `pipefail` the status is the + /// right-hand side's and the left-hand failure is invisible. + #[cfg(unix)] + #[test] + fn test_install_shell_pipeline_status_follows_left_side() { + for (command, expect_success) in [("false | true", false), ("echo ok | cat", true)] { + let status = super::install_shell_command(command) + .expect("Unix must always resolve an install shell") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("install shell must spawn"); + assert_eq!( + status.success(), + expect_success, + "`{command}` must report success={expect_success}; got {status:?}" + ); + } + } + // ── Phase A: Windows install shell selection ─────────────────────────────── /// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds. @@ -1700,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 0000000000..63163ceadc --- /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 2d7e13d1d7..72108f0291 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 25d3d4d1c4..273654e32a 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.rs b/desktop/src-tauri/src/commands/agent_models.rs index 425eadb5f6..ca1fe9bdf6 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -5,16 +5,23 @@ use serde::Deserialize; use tauri::{AppHandle, State}; use super::agent_model_process::run_agent_models_command; +// The map-only lookup is reached solely from the base-URL helpers that exist for +// their unit tests; discovery itself always goes through the process-env variant. +#[cfg(test)] +use super::agent_models_env::env_value; +use super::agent_models_env::{ + effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider, +}; use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback}; use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, - find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas, - managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, - save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, - AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, + find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, + load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args, + resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, + AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, @@ -31,7 +38,7 @@ pub async fn get_agent_models( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let (resolved_acp, agent_command, agent_args, persisted_model, effective_provider, merged_env) = { + let (resolved_acp, agent_command, discovery) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -62,34 +69,39 @@ pub async fn get_agent_models( // so model discovery runs against the persona's current harness, not the // frozen record snapshot. An explicit per-agent override wins. let personas = load_personas(&app).unwrap_or_default(); - let effective_command = crate::managed_agents::record_agent_command(record, &personas); + let global = load_global_agent_config(&app).unwrap_or_default(); - let args = normalize_agent_args(&effective_command, record.agent_args.clone()); + // Single pure helper — descriptor + authoritative model/provider + // resolver, packaged so the linked-agent regression test binds the + // exact values this command consumes. Returns Err on dangling harness + // id, propagating it to the caller. + let discovery = agent_model_discovery_config(record, &personas, &global) + .map_err(|e| model_discovery_error(&pubkey, &e))?; - let resolved_agent = resolve_command(&effective_command) + let resolved_agent = resolve_command(&discovery.command) .map(|p| p.display().to_string()) - .unwrap_or_else(|| effective_command.clone()); + .unwrap_or_else(|| discovery.command.clone()); - // ModelPicker can persist a selected model but not rewrite the saved - // provider/env snapshot, and runtime spawn reads that same snapshot. - // Discover models against the record snapshot so an out-of-date persona - // cannot offer models for a provider this agent will not launch with. - let discovery = saved_agent_model_discovery_config(record, &effective_command); - - ( - resolved, - resolved_agent, - args, - discovery.model, - discovery.provider, - discovery.env, - ) + (resolved, resolved_agent, discovery) }; // store lock released — subprocess runs without holding the lock + let AgentModelDiscoveryConfig { + args: agent_args, + model: persisted_model, + provider: saved_provider, + provider_env_var, + env: merged_env, + command: _, + } = discovery; + let merged_env = discovery_env_with_baked_floor(merged_env); + // Resolve against the baked/process env when the record saved no provider, + // so a build-provided provider still gets live discovery. + let effective_provider = + effective_discovery_provider(saved_provider.as_deref(), provider_env_var, &merged_env); if let Some(models) = discover_openai_compatible_models( &state.http_client, - effective_provider.as_deref(), + &effective_provider, &merged_env, persisted_model.clone(), ) @@ -100,7 +112,7 @@ pub async fn get_agent_models( if let Some(models) = discover_anthropic_models( &state.http_client, - effective_provider.as_deref(), + &effective_provider, &merged_env, persisted_model.clone(), ) @@ -111,7 +123,7 @@ pub async fn get_agent_models( if let Some(models) = discover_databricks_models( &state.http_client, - effective_provider.as_deref(), + &effective_provider, &merged_env, persisted_model.clone(), ) @@ -130,35 +142,80 @@ pub async fn get_agent_models( .await } +/// Error copy for a failed harness resolution during model discovery. +/// +/// Routes through `user_facing_harness_error` so a dangling harness id renders +/// as a sentence, never as the raw `DANGLING_HARNESS_ID:` sentinel — the same +/// contract spawn and summary rows honor. +fn model_discovery_error(pubkey: &str, error: &str) -> String { + format!( + "cannot discover models for {pubkey}: {}", + crate::managed_agents::user_facing_harness_error(error) + ) +} + +/// Everything `get_agent_models` needs from the record + context, resolved in +/// one pure step so the linked-agent regression test can bind the exact values +/// the command consumes. #[derive(Debug, PartialEq, Eq)] -struct SavedAgentModelDiscoveryConfig { +struct AgentModelDiscoveryConfig { + /// Effective harness command (descriptor-resolved), for `resolve_command`. + command: String, + /// Effective harness args (descriptor-resolved). + args: Vec, + /// Model from the authoritative resolver spawn uses — linked instances + /// read their definition, never stale `record.model` bytes. model: Option, + /// Provider from the same authoritative resolver — never stale + /// `record.provider` bytes for linked instances. provider: Option, + /// The runtime's provider env var (e.g. `GOOSE_PROVIDER`), so discovery + /// can recover the provider from the env when the resolver yields none. + /// `None` for runtimes that do not take a provider, or an unknown command. + provider_env_var: Option<&'static str>, + /// The descriptor's fully layered env (definition/persona/global/agent). env: BTreeMap, } -fn saved_agent_model_discovery_config( +/// Resolve the model-discovery config for a saved agent — the descriptor-backed +/// successor to the old `saved_agent_model_discovery_config`. +/// +/// Command/args/env come from `resolve_effective_harness_descriptor` (the same +/// resolver as `spawn_agent_child`); model/provider come from +/// `resolve_effective_model_provider` (#1968's definition-authoritative +/// contract) — linked instances read their definition, never a stale +/// materialized `record.model`/`record.provider`, so discovery cannot query a +/// provider this agent will not actually launch with. Definition-less +/// instances keep their own record values, matching spawn's +/// `resolve_definition_less` arm. When the resolver yields no provider, +/// `effective_discovery_provider` recovers the provider the agent will +/// actually launch with from the runtime's own provider env var, read out of +/// the descriptor env (which already layers definition/persona/global values +/// the same way spawn does). +/// +/// Returns `Err("DANGLING_HARNESS_ID:")` from the descriptor resolver when +/// the harness id no longer exists; the caller routes it through +/// `model_discovery_error`. +fn agent_model_discovery_config( record: &crate::managed_agents::ManagedAgentRecord, - agent_command: &str, -) -> SavedAgentModelDiscoveryConfig { - let mut derived_env = BTreeMap::new(); - if let Some(meta) = known_acp_runtime(agent_command) { - for (key, value) in crate::managed_agents::runtime_metadata_env_vars( - meta.model_env_var, - meta.provider_env_var, - meta.provider_locked, - record.model.as_deref(), - record.provider.as_deref(), - ) { - derived_env.insert(key.to_string(), value.to_string()); - } - } + personas: &[crate::managed_agents::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)?; + let (model, provider) = + crate::managed_agents::resolve_effective_model_provider(record, personas, global); + let provider_env_var = + known_acp_runtime(&descriptor.command).and_then(|meta| meta.provider_env_var); - SavedAgentModelDiscoveryConfig { - model: record.model.clone(), - provider: record.provider.clone(), - env: crate::managed_agents::merged_user_env(&derived_env, &record.env_vars), - } + Ok(AgentModelDiscoveryConfig { + command: descriptor.command, + args: descriptor.args, + model, + provider, + provider_env_var, + env: descriptor.env, + }) } #[derive(Debug, Deserialize)] @@ -173,6 +230,10 @@ pub struct DiscoverAgentModelsInput { pub provider: Option, #[serde(default)] pub env_vars: BTreeMap, + /// Definition-level env from the harness definition (custom/preset). + /// Merged below user `env_vars` so user overrides always win. + #[serde(default)] + pub definition_env: BTreeMap, } /// Query available models from an unsaved agent configuration. @@ -186,6 +247,8 @@ pub async fn discover_agent_models( state: State<'_, AppState>, ) -> Result { crate::managed_agents::validate_user_env_keys(&input.env_vars)?; + // Also validate definition_env (caller-supplied, same trust level as env_vars). + crate::managed_agents::validate_user_env_keys(&input.definition_env)?; let acp_command = input .acp_command @@ -205,8 +268,9 @@ pub async fn discover_agent_models( .map(|p| p.display().to_string()) .unwrap_or_else(|| agent_command.to_string()); + let runtime_meta = known_acp_runtime(agent_command); let mut derived_env = BTreeMap::new(); - if let Some(meta) = known_acp_runtime(agent_command) { + if let Some(meta) = runtime_meta { let provider = input .provider .as_deref() @@ -218,8 +282,26 @@ pub async fn discover_agent_models( } } } - let merged_env = crate::managed_agents::merged_user_env(&derived_env, &input.env_vars); + // Layer definition_env below user env_vars so user overrides always win. + // Reserved keys are stripped, matching the same filter applied at spawn. + let mut filtered_definition_env = BTreeMap::new(); + for (key, value) in &input.definition_env { + if !crate::managed_agents::is_reserved_env_key(key) { + filtered_definition_env.insert(key.clone(), value.clone()); + } + } + // Merge: derived (metadata) → definition env → user env_vars. + let merged_with_def = + crate::managed_agents::merged_user_env(&derived_env, &filtered_definition_env); + let merged_env = crate::managed_agents::merged_user_env(&merged_with_def, &input.env_vars); let merged_env = discovery_env_with_baked_floor(merged_env); + // Recover a build-provided provider when the form has none, so the create + // dialog discovers live models instead of falling through to the subprocess. + let effective_provider = effective_discovery_provider( + input.provider.as_deref(), + runtime_meta.and_then(|meta| meta.provider_env_var), + &merged_env, + ); // Buzz shared compute discovery must not depend on the local OpenAI ingress: that // client endpoint is started only after a live target is selected. @@ -268,7 +350,7 @@ pub async fn discover_agent_models( if let Some(models) = discover_openai_compatible_models( &state.http_client, - input.provider.as_deref(), + &effective_provider, &merged_env, None, ) @@ -277,24 +359,16 @@ pub async fn discover_agent_models( return Ok(models); } - if let Some(models) = discover_anthropic_models( - &state.http_client, - input.provider.as_deref(), - &merged_env, - None, - ) - .await? + if let Some(models) = + discover_anthropic_models(&state.http_client, &effective_provider, &merged_env, None) + .await? { return Ok(models); } - if let Some(models) = discover_databricks_models( - &state.http_client, - input.provider.as_deref(), - &merged_env, - None, - ) - .await? + if let Some(models) = + discover_databricks_models(&state.http_client, &effective_provider, &merged_env, None) + .await? { return Ok(models); } @@ -337,33 +411,6 @@ fn openai_compatible_models_url_for_discovery(env: &BTreeMap) -> format!("{}/models", base_url.trim_end_matches('/')) } -fn env_value(env: &BTreeMap, key: &str) -> Option { - env.get(key) - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn env_or_process_value(env: &BTreeMap, key: &str) -> Option { - env_value(env, key).or_else(|| { - std::env::var(key) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) -} - -fn redaction_env_with_value( - env: &BTreeMap, - key: &str, - value: &str, -) -> BTreeMap { - let mut redaction_env = env.clone(); - redaction_env.insert(key.to_string(), value.to_string()); - redaction_env -} - fn is_agent_text_model_id(id: &str) -> bool { let lower = id.to_ascii_lowercase(); if [ @@ -482,20 +529,23 @@ fn normalize_openai_compatible_models( async fn discover_openai_compatible_models( client: &reqwest::Client, - provider: Option<&str>, + provider: &DiscoveryProvider, env: &BTreeMap, selected_model: Option, ) -> Result, String> { - let relay_mesh = provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); - if !relay_mesh && !is_openai_compatible_provider(provider) { + let relay_mesh = + provider.as_deref().map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); + if !relay_mesh && !is_openai_compatible_provider(provider.as_deref()) { return Ok(None); } let api_key = if relay_mesh { crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string() } else { - env_or_process_value(env, "OPENAI_COMPAT_API_KEY") - .ok_or_else(|| "config: OPENAI_COMPAT_API_KEY required".to_string())? + match provider.required_env(env, "OPENAI_COMPAT_API_KEY")? { + Some(api_key) => api_key, + None => return Ok(None), + } }; let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key); let url = if relay_mesh { @@ -520,13 +570,13 @@ async fn discover_openai_compatible_models( .json::() .await .map_err(|error| format!("OpenAI model discovery response parse failed: {error}"))?; - let models = normalize_openai_compatible_models(response, provider); + let models = normalize_openai_compatible_models(response, provider.as_deref()); if models.is_empty() { return Err("OpenAI model discovery returned no compatible text models".to_string()); } Ok(Some(AgentModelsResponse { - agent_name: provider.unwrap_or("openai").trim().to_string(), + agent_name: provider.as_deref().unwrap_or("openai").trim().to_string(), agent_version: "models-api".to_string(), models, agent_default_model: None, @@ -631,16 +681,18 @@ async fn fetch_anthropic_model_page( async fn discover_anthropic_models( client: &reqwest::Client, - provider: Option<&str>, + provider: &DiscoveryProvider, env: &BTreeMap, selected_model: Option, ) -> Result, String> { - if !is_anthropic_provider(provider) { + if !is_anthropic_provider(provider.as_deref()) { return Ok(None); } - let api_key = env_or_process_value(env, "ANTHROPIC_API_KEY") - .ok_or_else(|| "config: ANTHROPIC_API_KEY required".to_string())?; + let api_key = match provider.required_env(env, "ANTHROPIC_API_KEY")? { + Some(api_key) => api_key, + None => return Ok(None), + }; let redaction_env = redaction_env_with_value(env, "ANTHROPIC_API_KEY", &api_key); let url = anthropic_models_url_for_discovery(env); let mut models = Vec::new(); @@ -666,7 +718,11 @@ async fn discover_anthropic_models( } Ok(Some(AgentModelsResponse { - agent_name: provider.unwrap_or("anthropic").trim().to_string(), + agent_name: provider + .as_deref() + .unwrap_or("anthropic") + .trim() + .to_string(), agent_version: "models-api".to_string(), models, agent_default_model: None, @@ -708,11 +764,11 @@ fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider async fn discover_databricks_models( _client: &reqwest::Client, - provider: Option<&str>, + provider: &DiscoveryProvider, env: &BTreeMap, selected_model: Option, ) -> Result, String> { - let provider_str = match provider { + let provider_str = match provider.as_deref() { Some(p) if is_databricks_provider(Some(p)) => p, _ => return Ok(None), }; @@ -767,6 +823,35 @@ async fn discover_databricks_models( })) } +/// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch +/// to `record`, enforcing the linked-instance write guard: a definition-linked +/// record's model/provider/prompt are definition-authoritative (see +/// `effective_config::resolve_linked`), so writes to these three fields are +/// silently dropped for a linked instance rather than persisting a byte the +/// resolver will never read. Definition-less instances accept the patch +/// as-is. Extracted so the guard is exercised by both `update_managed_agent` +/// and its regression tests — a test that reimplements this check instead of +/// calling it can go green after the real guard is deleted. +fn apply_model_provider_prompt_update( + record: &mut crate::managed_agents::ManagedAgentRecord, + model: Option>, + provider: Option>, + system_prompt: Option>, +) { + if record.persona_id.is_some() { + return; + } + if let Some(model_update) = model { + record.model = model_update; + } + if let Some(provider_update) = provider { + record.provider = provider_update; + } + if let Some(prompt_update) = system_prompt { + record.system_prompt = prompt_update; + } +} + /// Update mutable fields on an existing managed agent record. /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, @@ -806,15 +891,12 @@ pub async fn update_managed_agent( name_changed = true; } } - if let Some(model_update) = input.model { - record.model = model_update; - } - if let Some(provider_update) = input.provider { - record.provider = provider_update; - } - if let Some(prompt_update) = input.system_prompt { - record.system_prompt = prompt_update; - } + apply_model_provider_prompt_update( + record, + input.model, + input.provider, + input.system_prompt, + ); if let Some(parallelism) = input.parallelism { record.parallelism = parallelism; } @@ -938,7 +1020,13 @@ pub async fn update_managed_agent( let summary = { let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary(&app, record, &runtimes, &personas)? + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + )? }; let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record)); (summary, sync_params, rollback) diff --git a/desktop/src-tauri/src/commands/agent_models_env.rs b/desktop/src-tauri/src/commands/agent_models_env.rs new file mode 100644 index 0000000000..0a40b6bd8f --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_env.rs @@ -0,0 +1,114 @@ +//! Env lookups shared by the in-process model discovery paths. +//! +//! Discovery reads configuration from a merged env map (record/form env over the +//! runtime-derived env, under the baked build floor) and falls back to the +//! process env for values a GUI launch never wrote into the map. + +use std::collections::BTreeMap; + +/// Read a non-blank value from the merged discovery env. +pub(super) fn env_value(env: &BTreeMap, key: &str) -> Option { + env.get(key) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// Read a non-blank value from the merged discovery env, then the process env. +pub(super) fn env_or_process_value(env: &BTreeMap, key: &str) -> Option { + env_value(env, key).or_else(|| { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +/// Clone `env` with `key` set to the value a request actually used, so error +/// redaction masks the inherited process value and not just the mapped one. +pub(super) fn redaction_env_with_value( + env: &BTreeMap, + key: &str, + value: &str, +) -> BTreeMap { + let mut redaction_env = env.clone(); + redaction_env.insert(key.to_string(), value.to_string()); + redaction_env +} + +/// A provider resolved for discovery, plus how it was resolved. +/// +/// The distinction decides what a missing credential means. An explicit provider +/// is an assertion — the record or the dialog says this agent runs on Anthropic, +/// so a missing `ANTHROPIC_API_KEY` is a real misconfiguration and the user +/// should see it. An inferred provider is only a guess read off the environment, +/// and a wrong guess must not replace a working catalog with an error: a +/// `GOOSE_PROVIDER=anthropic` export (goose's documented way to pick a provider, +/// with the key in goose's own keyring rather than Buzz's env) would otherwise +/// turn the subprocess catalog into `config: ANTHROPIC_API_KEY required`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct DiscoveryProvider { + value: Option, + inferred: bool, +} + +impl DiscoveryProvider { + pub(super) fn as_deref(&self) -> Option<&str> { + self.value.as_deref() + } + + /// Read a credential the provider's discovery cannot run without. + /// + /// `Ok(None)` means "not configured, and the provider was only inferred" — + /// the caller must fall through to the `buzz-acp models` subprocess rather + /// than surface an error. + pub(super) fn required_env( + &self, + env: &BTreeMap, + key: &str, + ) -> Result, String> { + match env_or_process_value(env, key) { + Some(value) => Ok(Some(value)), + None if self.inferred => Ok(None), + None => Err(format!("config: {key} required")), + } + } +} + +/// Resolve the provider that live model discovery should run against. +/// +/// An explicit provider — the agent record's saved provider, or the create/edit +/// dialog's current form value — always wins. When there is none, fall back to +/// the provider the agent will actually launch with: the runtime's provider env +/// var read out of `env`, which by this point already carries the baked build +/// floor (see `discovery_env_with_baked_floor`) and the process env. +/// +/// Without this fallback, every provider gate sees `None` for an agent whose +/// record predates provider persistence, so no in-process discovery runs at all +/// — even on an internal build that bakes `BUZZ_AGENT_PROVIDER=databricks_v2` +/// and a `DATABRICKS_HOST`. Discovery then degrades to the `buzz-acp models` +/// subprocess, which on a Databricks failure path surfaces the small +/// known-models fallback catalog instead of the live gateway list — +/// indistinguishable, from the picker's side, from the real thing. +pub(super) fn effective_discovery_provider( + provider: Option<&str>, + provider_env_var: Option<&str>, + env: &BTreeMap, +) -> DiscoveryProvider { + if let Some(explicit) = provider + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + { + return DiscoveryProvider { + value: Some(explicit), + inferred: false, + }; + } + + DiscoveryProvider { + value: provider_env_var.and_then(|key| env_or_process_value(env, key)), + inferred: true, + } +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0f99927c9e..b65f240900 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -164,7 +164,7 @@ fn redaction_env_records_value_used_for_request() { } #[test] -fn saved_agent_model_discovery_uses_record_snapshot() { +fn saved_agent_model_discovery_uses_record_snapshot_for_definition_less_agent() { let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( r#"{ "pubkey": "abcd1234", @@ -173,6 +173,7 @@ fn saved_agent_model_discovery_uses_record_snapshot() { "relay_url": "wss://localhost:3000", "acp_command": "buzz-acp", "agent_command": "goose", + "agent_command_override": "goose", "agent_args": [], "mcp_command": "", "turn_timeout_seconds": 320, @@ -193,23 +194,239 @@ fn saved_agent_model_discovery_uses_record_snapshot() { ) .expect("sample managed agent record"); - let config = saved_agent_model_discovery_config(&record, "goose"); + // agent_model_discovery_config is the single helper get_agent_models + // consumes — verify it layers env correctly, strips reserved keys, and + // keeps the record's own model/provider for a definition-less instance + // (matching spawn's `resolve_definition_less` arm). + let discovery = agent_model_discovery_config(&record, &[], &Default::default()) + .expect("discovery config should resolve for a valid record"); - assert_eq!(config.model.as_deref(), Some("record-model")); - assert_eq!(config.provider.as_deref(), Some("databricks")); + assert_eq!(discovery.command.as_str(), "goose"); + assert_eq!(discovery.model.as_deref(), Some("record-model")); + assert_eq!(discovery.provider.as_deref(), Some("databricks")); assert_eq!( - config.env.get("GOOSE_MODEL").map(String::as_str), + discovery.env.get("GOOSE_MODEL").map(String::as_str), Some("record-model") ); assert_eq!( - config.env.get("GOOSE_PROVIDER").map(String::as_str), + discovery.env.get("GOOSE_PROVIDER").map(String::as_str), Some("databricks") ); assert_eq!( - config.env.get("OPENAI_API_KEY").map(String::as_str), + discovery.env.get("OPENAI_API_KEY").map(String::as_str), Some("record-key") ); - assert!(!config.env.contains_key("BUZZ_PRIVATE_KEY")); + // Reserved keys are stripped from the descriptor env. + assert!(!discovery.env.contains_key("BUZZ_PRIVATE_KEY")); + // The provider env var is recovered from the runtime metadata for the + // effective command (the old SavedAgentModelDiscoveryConfig.provider_env_var). + assert_eq!(discovery.provider_env_var, Some("GOOSE_PROVIDER")); +} + +// --------------------------------------------------------------------------- +// Provider resolution for discovery +// --------------------------------------------------------------------------- + +#[test] +fn effective_discovery_provider_prefers_the_explicit_provider() { + let env = BTreeMap::from([( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string(), + )]); + + // A saved/selected provider is a deliberate choice and must win over the + // build-provided default, so discovery matches what spawn will use. + assert_eq!( + effective_discovery_provider(Some("anthropic"), Some("BUZZ_AGENT_PROVIDER"), &env) + .as_deref(), + Some("anthropic") + ); +} + +#[test] +fn effective_discovery_provider_recovers_baked_provider_when_record_has_none() { + let env = BTreeMap::from([( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string(), + )]); + + // The regression this guards: records predating provider persistence carry + // `provider: null`, so every discovery gate saw None and no live Databricks + // catalog was ever fetched on builds that bake the provider in. + for provider in [None, Some(""), Some(" ")] { + assert_eq!( + effective_discovery_provider(provider, Some("BUZZ_AGENT_PROVIDER"), &env).as_deref(), + Some("databricks_v2"), + "provider input {provider:?} must fall back to the env value" + ); + } +} + +#[test] +fn effective_discovery_provider_is_none_without_an_explicit_or_env_provider() { + let env = BTreeMap::new(); + assert_eq!( + effective_discovery_provider(None, Some("BUZZ_AGENT_PROVIDER"), &env).as_deref(), + None + ); + // A runtime that takes no provider env var has nothing to recover from. + assert_eq!( + effective_discovery_provider( + None, + None, + &BTreeMap::from([( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string() + )]) + ) + .as_deref(), + None + ); +} + +/// A credential name no environment sets, so `required_env` is exercised without +/// depending on what the developer happens to have exported. +const UNSET_CREDENTIAL: &str = "BUZZ_TEST_UNSET_DISCOVERY_CREDENTIAL"; + +#[test] +fn env_derived_provider_falls_through_when_its_credential_is_missing() { + let env = BTreeMap::from([("GOOSE_PROVIDER".to_string(), "anthropic".to_string())]); + let inferred = effective_discovery_provider(None, Some("GOOSE_PROVIDER"), &env); + assert_eq!(inferred.as_deref(), Some("anthropic")); + + // `export GOOSE_PROVIDER=anthropic` is goose's documented way to pick a + // provider, and it keeps the API key in its own config/keyring rather than in + // Buzz's env — so the provider is visible here and the credential is not. + // Erroring would swap the working subprocess catalog for a hard + // "config: ... required" on exactly the null-provider records this fallback + // exists to serve; the gate has to decline instead. + assert_eq!(inferred.required_env(&env, UNSET_CREDENTIAL), Ok(None)); +} + +#[test] +fn explicit_provider_still_reports_a_missing_credential() { + // An explicit provider is an assertion about this agent, so a missing + // credential is a real misconfiguration and stays user-visible. + let env = BTreeMap::new(); + let explicit = effective_discovery_provider(Some("anthropic"), Some("GOOSE_PROVIDER"), &env); + assert_eq!( + explicit.required_env(&env, UNSET_CREDENTIAL), + Err(format!("config: {UNSET_CREDENTIAL} required")) + ); +} + +#[test] +fn required_env_returns_a_configured_credential_however_the_provider_was_resolved() { + let env = BTreeMap::from([ + ("GOOSE_PROVIDER".to_string(), "anthropic".to_string()), + ( + UNSET_CREDENTIAL.to_string(), + " sk-configured ".to_string(), + ), + ]); + for provider in [Some("anthropic"), None] { + let resolved = effective_discovery_provider(provider, Some("GOOSE_PROVIDER"), &env); + assert_eq!( + resolved.required_env(&env, UNSET_CREDENTIAL), + Ok(Some("sk-configured".to_string())), + "provider input {provider:?} must read the configured credential" + ); + } +} + +#[test] +fn effective_discovery_provider_reads_the_runtimes_own_env_var() { + // goose keys its provider off GOOSE_PROVIDER, so a BUZZ_AGENT_PROVIDER in + // the env must not be mistaken for this runtime's provider. + let env = BTreeMap::from([ + ("GOOSE_PROVIDER".to_string(), "databricks".to_string()), + ( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string(), + ), + ]); + assert_eq!( + effective_discovery_provider(None, Some("GOOSE_PROVIDER"), &env).as_deref(), + Some("databricks") + ); +} + +/// Definition-authoritative: a linked agent's stale materialized +/// `record.model`/`record.provider` must never drive model discovery — the +/// linked definition's current model/provider wins, mirroring spawn's +/// `resolve_effective_model_provider`. +#[test] +fn model_discovery_ignores_stale_record_for_linked_agent() { + let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "persona_id": "persona-1", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": "stale-record-model", + "provider": "stale-record-provider", + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("sample managed agent record"); + + let persona = crate::managed_agents::AgentDefinition { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + avatar_url: None, + system_prompt: "You are a persona.".to_string(), + runtime: Some("goose".to_string()), + model: Some("persona-model".to_string()), + provider: Some("anthropic".to_string()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + }; + + // agent_model_discovery_config is the single helper get_agent_models + // consumes — the stale record bytes must lose to the persona's current + // model/provider (the same authoritative resolver spawn uses). + let personas = [persona]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + let discovery = agent_model_discovery_config(&record, &personas, &global) + .expect("discovery config should resolve for a linked record"); + assert_eq!(discovery.model.as_deref(), Some("persona-model")); + assert_eq!(discovery.provider.as_deref(), Some("anthropic")); + + // And the discovery env comes from the descriptor, whose layering also + // resolves through the definition — the derived model env var must carry + // the persona's model, not the stale record snapshot. + assert_eq!( + discovery.env.get("GOOSE_MODEL").map(String::as_str), + Some("persona-model") + ); + assert_eq!( + discovery.env.get("GOOSE_PROVIDER").map(String::as_str), + Some("anthropic") + ); } // --------------------------------------------------------------------------- @@ -252,6 +469,104 @@ fn update_request_turn_timeout_parses_for_wire_compat() { assert_eq!(req.turn_timeout_seconds, Some(9999)); } +// --------------------------------------------------------------------------- +// Linked-instance write guard (model/provider/prompt) +// --------------------------------------------------------------------------- + +#[test] +fn linked_instance_ignores_model_provider_prompt_writes() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "linked1", + "name": "linked-agent", + "persona_id": "p1", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("linked agent record"); + + let is_linked = record.persona_id.is_some(); + assert!(is_linked, "test setup: record must be linked"); + + crate::commands::agent_models::apply_model_provider_prompt_update( + &mut record, + Some(Some("explicit-model".to_string())), + Some(Some("explicit-prov".to_string())), + Some(Some("explicit-prompt".to_string())), + ); + + assert!( + record.model.is_none(), + "linked record model must not be updated" + ); + assert!( + record.provider.is_none(), + "linked record provider must not be updated" + ); + assert!( + record.system_prompt.is_none(), + "linked record system_prompt must not be updated" + ); +} + +#[test] +fn definition_less_instance_accepts_model_provider_prompt_writes() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "standalone1", + "name": "standalone-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("standalone agent record"); + + let is_linked = record.persona_id.is_some(); + assert!(!is_linked, "test setup: record must not be linked"); + + crate::commands::agent_models::apply_model_provider_prompt_update( + &mut record, + Some(Some("new-model".to_string())), + Some(Some("new-prov".to_string())), + Some(Some("new-prompt".to_string())), + ); + + assert_eq!(record.model.as_deref(), Some("new-model")); + assert_eq!(record.provider.as_deref(), Some("new-prov")); + assert_eq!(record.system_prompt.as_deref(), Some("new-prompt")); +} + #[test] fn is_databricks_provider_matches_both_variants() { assert!(is_databricks_provider(Some("databricks"))); @@ -260,3 +575,18 @@ fn is_databricks_provider_matches_both_variants() { assert!(!is_databricks_provider(Some("anthropic"))); assert!(!is_databricks_provider(None)); } + +#[test] +fn model_discovery_error_converts_dangling_sentinel_to_sentence() { + // get_agent_models is a user-facing surface: a dangling harness must + // render as a sentence, never as the raw DANGLING_HARNESS_ID: sentinel. + let raw = format!("{}doomed", crate::managed_agents::DANGLING_HARNESS_PREFIX); + let msg = model_discovery_error("agent-pk", &raw); + assert!(msg.contains("cannot discover models for agent-pk")); + assert!(msg.contains("\"doomed\"") && msg.contains("deleted")); + assert!(!msg.contains(crate::managed_agents::DANGLING_HARNESS_PREFIX)); + + // Non-dangling errors pass through untouched. + let plain = model_discovery_error("agent-pk", "plain failure"); + assert_eq!(plain, "cannot discover models for agent-pk: plain failure"); +} diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 4f043d2403..2317930c1e 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -57,7 +57,13 @@ pub async fn set_managed_agent_start_on_app_launch( .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary(&app, record, &runtimes, &personas) + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + ) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -102,7 +108,13 @@ pub async fn set_managed_agent_auto_restart( .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary(&app, record, &runtimes, &personas) + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + ) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c65900b5de..0758fc3aac 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 { @@ -293,16 +249,16 @@ fn resolve_created_avatar_url( #[cfg(feature = "mesh-llm")] async fn ensure_relay_mesh_for_record( app: &AppHandle, - record: &ManagedAgentRecord, + model_id: Option<&str>, allow_fresh_create_start: bool, ) -> Result<(), String> { - crate::commands::ensure_relay_mesh_for_record(app, record, allow_fresh_create_start).await + crate::commands::ensure_relay_mesh_for_record(app, model_id, allow_fresh_create_start).await } #[cfg(not(feature = "mesh-llm"))] async fn ensure_relay_mesh_for_record( _app: &AppHandle, - _record: &ManagedAgentRecord, + _model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { Ok(()) @@ -327,7 +283,16 @@ pub(super) async fn start_local_agent_pairs_with_preflight( if record_snapshot.backend != BackendKind::Local { return Err(format!("agent {pubkey} is not a local agent")); } - ensure_relay_mesh_for_record(app, &record_snapshot, false).await?; + let personas_for_preflight = load_personas(app).unwrap_or_default(); + let global_for_preflight = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas_for_preflight, + &global_for_preflight, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; { let _store_guard = state @@ -380,7 +345,13 @@ pub(super) async fn start_local_agent_pairs_with_preflight( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - build_managed_agent_summary(app, record, &runtimes, &personas) + build_managed_agent_summary( + app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), + ) } pub(super) async fn start_local_agent_with_preflight( @@ -407,7 +378,22 @@ pub(super) async fn start_local_agent_with_preflight( return Err(format!("agent {pubkey} is not a local agent")); } - ensure_relay_mesh_for_record(app, &record_snapshot, allow_fresh_create_start).await?; + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute: this reads the CURRENT definition + // directly, so a definition edit that flips `provider` to/from relay-mesh + // between saves is reflected here without needing a prospective re-snapshot; + // for a global-inherited blank definition, it also folds in the global + // default, which record-byte sniffing could never see. + let personas = load_personas(app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas, + &global, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; let _store_guard = state .managed_agents_store_lock @@ -431,9 +417,16 @@ pub(super) async fn start_local_agent_with_preflight( // at the end — avoids a second disk read for the same file in the same call. let personas = load_personas(app).unwrap_or_default(); if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); + match personas.iter().find(|p| p.id == persona_id) { + Some(persona) => { + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + None => { + return Err( + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + ); + } } } start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?; @@ -445,7 +438,13 @@ pub(super) async fn start_local_agent_with_preflight( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - build_managed_agent_summary(app, record, &runtimes, &personas) + build_managed_agent_summary( + app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), + ) } /// Deploy an agent to a provider backend. Resolves the binary, calls deploy via @@ -546,9 +545,16 @@ pub async fn list_managed_agents(app: AppHandle) -> Result Err(format!( "agent {pubkey} has unsupported backend kind: {backend:?}" @@ -1226,7 +1258,13 @@ pub async fn stop_managed_agent( .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary(&app, record, &runtimes, &personas) + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + ) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index be132225ea..af785711d5 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -4,42 +4,36 @@ use tauri::AppHandle; +#[cfg(test)] +use crate::managed_agents::AgentDefinition; use crate::{ app_state::AppState, - managed_agents::{load_personas, AgentDefinition, ManagedAgentRecord}, + managed_agents::{load_personas, ManagedAgentRecord}, relay::relay_ws_url_with_override, }; /// Resolve the deploy-specific structured model/provider for a managed agent. /// -/// Deploy uses **live-persona-first** precedence so remote agents receive -/// current config after a persona update, without requiring delete+recreate. -/// Unlike local spawn (which re-snapshots the persona onto `record` at the -/// start of every spawn), provider start does not re-snapshot — so the -/// record may hold a stale snapshot while the linked persona has moved on. +/// Delegates to the single effective-config resolver which enforces +/// definition-authoritative semantics for linked instances: +/// - **Linked:** definition → global. Stale record bytes are never consulted. +/// - **Definition-less:** instance → global. +/// - **Orphaned:** returns `(None, None)` — spawn is blocked elsewhere. /// -/// Precedence: live-persona → record (snapshot fallback) → global. -/// Symmetric for both model and provider. +/// Both local spawn and deploy now use the same resolver, so they can never +/// disagree on what model/provider an agent runs with. /// /// Exported `pub(crate)` for unit testing. -pub(crate) fn resolve_deploy_model_provider<'a>( - record: &'a ManagedAgentRecord, - personas: &'a [AgentDefinition], - global: &'a crate::managed_agents::GlobalAgentConfig, -) -> (Option<&'a str>, Option<&'a str>) { - let live_persona = record - .persona_id - .as_deref() - .and_then(|pid| personas.iter().find(|p| p.id == pid)); - let model = live_persona - .and_then(|p| p.model.as_deref()) - .or(record.model.as_deref()) - .or(global.model.as_deref()); - let provider = live_persona - .and_then(|p| p.provider.as_deref()) - .or(record.provider.as_deref()) - .or(global.provider.as_deref()); - (model, provider) +#[cfg(test)] +pub(crate) fn resolve_deploy_model_provider( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> (Option, Option) { + crate::managed_agents::effective_config::resolve_effective_model_provider_pair( + record, personas, global, + ) + .unwrap_or((None, None)) } /// Build the standard agent JSON payload for provider deploy calls. @@ -84,15 +78,16 @@ pub(super) fn build_deploy_payload( let merged_env = crate::managed_agents::merged_user_env(&global_persona_merged, &record.env_vars); - // Resolve the deploy-specific structured provider/model. Uses the deploy - // resolver with live-persona → record → global precedence. let personas = load_personas(app).unwrap_or_default(); - let (effective_model, effective_provider) = - resolve_deploy_model_provider(record, &personas, &global_config); - let (effective_model, effective_provider) = ( - effective_model.map(str::to_string), - effective_provider.map(str::to_string), - ); + let cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, + &personas, + &global_config, + ) + .require_resolved()?; + let effective_model = cfg.model.value; + let effective_provider = cfg.provider.value; + let effective_prompt = cfg.system_prompt.value; Ok(deploy_payload_json( record, @@ -102,6 +97,7 @@ pub(super) fn build_deploy_payload( ), effective_model, effective_provider, + effective_prompt, merged_env, )) } @@ -114,36 +110,25 @@ pub(super) fn deploy_payload_json( relay_url: String, effective_model: Option, effective_provider: Option, + effective_prompt: Option, merged_env: std::collections::BTreeMap, ) -> serde_json::Value { serde_json::json!({ "name": &record.name, - // Resolve the per-agent pin against the active workspace relay here: - // this payload crosses the host boundary to a remote provider harness - // that has no notion of the desktop's workspace, so the blank→workspace - // fallback (otherwise applied at read-time in `effective_agent_relay_url`) - // must be materialized into a concrete URL before serializing. "relay_url": relay_url, "private_key_nsec": &record.private_key_nsec, "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, "agent_args": &record.agent_args, - "system_prompt": &record.system_prompt, + "system_prompt": effective_prompt, "model": effective_model, - // Structured provider from the persona record. Providers that don't - // yet read this field will fall back to env_vars or their own default - // — no protocol break. "provider": effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, "parallelism": record.parallelism, - // Inbound author gate. Providers that don't yet read these fall back - // to the harness default (`owner-only`) — no protocol break. "respond_to": record.respond_to, "respond_to_allowlist": &record.respond_to_allowlist, - // Merged persona + agent env vars. Providers that don't read this - // field will simply ignore it — no protocol break. "env_vars": merged_env, }) } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179..03389d1d18 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![], @@ -126,57 +130,56 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { })); } -/// Deploy-path regression for Fix 1 of Thufir pass-2: a persona-linked -/// provider agent with a stale record snapshot must use the live persona -/// model/provider in the deploy payload, not the stale record values. -/// -/// Scenario: agent was created with persona at model="old-model"/provider="old-prov". -/// The persona was subsequently updated to "new-model"/"new-prov" but the record -/// was NOT re-snapshotted (provider start skips re-snapshot; local spawn does it). -/// The deploy resolver must use the current persona values. -/// -/// Fails against `resolve_effective_model_provider` (record-first precedence), -/// which would return "old-model"/"old-prov" from the stale record. +/// Deploy resolver uses definition model/provider, ignoring stale record. #[test] -fn deploy_resolver_uses_live_persona_over_stale_record_snapshot() { - // Record holds the stale snapshot (created when persona had old values). +fn deploy_resolver_uses_definition_over_stale_record() { let record = bare_agent_record(Some("p1"), Some("old-model"), Some("old-prov")); - // Live persona has been updated since the record was snapshotted. let personas = vec![persona_record("p1", Some("new-model"), Some("new-prov"))]; let global = crate::managed_agents::GlobalAgentConfig::default(); let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("new-model"), - "deploy must use live persona model, not stale record snapshot" + "deploy must use definition model, not stale record snapshot" ); assert_eq!( - provider, + provider.as_deref(), Some("new-prov"), - "deploy must use live persona provider, not stale record snapshot" + "deploy must use definition provider, not stale record snapshot" ); } -/// Deploy resolver falls back to record when persona has no model/provider -/// (persona without structured model — fallback to record snapshot). +/// When a linked definition has blank model/provider (inherit), the deploy +/// resolver must fall through to global — stale record bytes are inert. #[test] -fn deploy_resolver_falls_back_to_record_when_persona_has_none() { - let record = bare_agent_record(Some("p1"), Some("record-model"), Some("record-prov")); - // Persona exists but has no model/provider. +fn deploy_resolver_inherits_global_when_definition_blank() { + let record = bare_agent_record(Some("p1"), Some("stale-model"), Some("stale-prov")); let personas = vec![persona_record("p1", None, None)]; - let global = crate::managed_agents::GlobalAgentConfig::default(); + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-prov".to_string()), + ..Default::default() + }; let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model")); - assert_eq!(provider, Some("record-prov")); + assert_eq!( + model.as_deref(), + Some("global-model"), + "definition blank → global; stale record ignored" + ); + assert_eq!( + provider.as_deref(), + Some("global-prov"), + "definition blank → global; stale record ignored" + ); } -/// Deploy resolver falls back to global when both persona and record have none. +/// Deploy resolver falls back to global when both definition and record have none. #[test] -fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { +fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() { let record = bare_agent_record(Some("p1"), None, None); let personas = vec![persona_record("p1", None, None)]; let global = crate::managed_agents::GlobalAgentConfig { @@ -187,8 +190,35 @@ fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); - assert_eq!(model, Some("global-model")); - assert_eq!(provider, Some("global-prov")); + assert_eq!(model.as_deref(), Some("global-model")); + assert_eq!(provider.as_deref(), Some("global-prov")); +} + +/// Orphan: linked record with missing definition → the pure model/provider +/// pair resolver returns `(None, None)`. This is NOT the deploy refusal +/// boundary — `build_deploy_payload` refuses an orphan outright via +/// `.require_resolved()?` before this pair is ever computed. This test pins +/// the resolver's own orphan behavior, which readiness/hash also depend on. +#[test] +fn deploy_resolver_returns_none_for_orphaned_instance() { + let record = bare_agent_record(Some("missing-def"), Some("stale-model"), Some("stale-prov")); + let personas: Vec = vec![]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-prov".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); + + assert!( + model.is_none(), + "orphaned instance must not resolve to any model" + ); + assert!( + provider.is_none(), + "orphaned instance must not resolve to any provider" + ); } #[test] @@ -404,6 +434,7 @@ fn deploy_payload_carries_the_full_behavioral_quad() { "wss://relay.example".to_string(), Some("gpt-x".to_string()), Some("openai".to_string()), + None, std::collections::BTreeMap::new(), ); diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 33783c05a5..2840c0ade6 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 0000000000..d2a357c4f9 --- /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 bf8692ff70..ed3b340238 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 016865878e..d3b1a9499d 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -610,6 +610,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 +660,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 +706,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index f2593ff9e0..734d8f5dc8 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/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 9a0a3c32eb..305c54a203 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager, State}; use crate::{app_state::AppState, mesh_llm, relay}; @@ -57,8 +58,85 @@ fn share_stop_should_teardown(mode: mesh_llm::MeshNodeMode) -> bool { matches!(mode, mesh_llm::MeshNodeMode::Serve) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeshStartPlan { + Start, + RestartToReplaceClient, + RejectOccupied, +} + +fn mesh_start_plan( + requested_mode: mesh_llm::MeshNodeMode, + existing_mode: Option, +) -> MeshStartPlan { + match (requested_mode, existing_mode) { + (_, None) => MeshStartPlan::Start, + (mesh_llm::MeshNodeMode::Serve, Some(mesh_llm::MeshNodeMode::Client)) => { + MeshStartPlan::RestartToReplaceClient + } + _ => MeshStartPlan::RejectOccupied, + } +} + +fn sharing_config_from_request( + request: &mesh_llm::StartMeshNodeRequest, +) -> CmdResult { + let model_id = request + .model_id + .as_deref() + .map(str::trim) + .filter(|model_id| !model_id.is_empty()) + .ok_or_else(|| "modelId is required for serve mode".to_string())?; + Ok(MeshSharingConfig { + enabled: true, + model_id: model_id.to_string(), + max_vram_gb: request.max_vram_gb, + }) +} + +fn restarting_share_status(config: &MeshSharingConfig) -> mesh_llm::MeshNodeStatus { + mesh_llm::MeshNodeStatus { + state: mesh_llm::MeshNodeState::Starting, + mode: Some(mesh_llm::MeshNodeMode::Serve), + health: mesh_llm::MeshHealth { + status: mesh_llm::MeshHealthStatus::Degraded, + reason: Some("Buzz is restarting to switch this machine to sharing".to_string()), + }, + api_base_url: None, + console_url: None, + model_id: Some(config.model_id.clone()), + model_name: Some(config.model_id.clone()), + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + } +} + +fn restart_to_share( + app: &AppHandle, + config: &MeshSharingConfig, +) -> CmdResult { + save_mesh_sharing_config(app, config)?; + let status = restarting_share_status(config); + app.request_restart(); + Ok(status) +} + pub type CmdResult = Result; +fn buzz_mesh_name_for_relay(relay_url: &str) -> String { + let normalized = url::Url::parse(relay_url.trim()) + .map(|url| url.origin().ascii_serialization()) + .unwrap_or_else(|_| relay_url.trim().trim_end_matches('/').to_ascii_lowercase()); + let digest = hex::encode(Sha256::digest(normalized.as_bytes())); + format!("buzz-community-{}", &digest[..32]) +} + +fn buzz_mesh_name(state: &AppState) -> String { + buzz_mesh_name_for_relay(&relay::relay_ws_url_with_override(state)) +} + fn advance_mesh_status_cursor( filter: &mut serde_json::Value, page: &[nostr::Event], @@ -137,6 +215,81 @@ pub(crate) async fn resolve_trusted_owner_ids_or_self_only(state: &AppState) -> } } +/// Choose validated live endpoints from other runtimes in this Buzz community. +/// The stable relay-derived mesh name gives every runtime the same MeshLLM mesh +/// identity; these endpoints supply transport bootstrap only. +fn buzz_mesh_join_targets( + mut targets: Vec, + self_owner_id: &str, +) -> Vec { + targets.retain(|target| { + target.reporter_pubkey.is_some() + && target + .owner_id + .as_deref() + .is_some_and(|owner| !owner.eq_ignore_ascii_case(self_owner_id.trim())) + }); + targets.sort_by(|left, right| { + left.reporter_pubkey + .cmp(&right.reporter_pubkey) + .then_with(|| left.owner_id.cmp(&right.owner_id)) + .then_with(|| left.endpoint_id.cmp(&right.endpoint_id)) + .then_with(|| left.endpoint_addr.cmp(&right.endpoint_addr)) + .then_with(|| left.model_id.cmp(&right.model_id)) + }); + targets.dedup_by(|left, right| left.endpoint_addr == right.endpoint_addr); + targets +} + +/// Resolve the validated member endpoint this runtime should join to enter the +/// existing Buzz community mesh. `Ok(None)` means this machine is the first +/// live serving member (or is itself the shared bootstrap contact). +pub(crate) async fn resolve_buzz_mesh_join_targets( + state: &AppState, +) -> Result, String> { + let events = query_mesh_discovery_events(state).await?; + let self_owner_id = mesh_llm::ensure_owner_identity() + .map_err(|error| format!("failed to load mesh owner identity: {error}"))? + .owner_id; + Ok(buzz_mesh_join_targets( + mesh_llm::availability_from_events(events).serve_targets, + &self_owner_id, + )) +} + +/// Resolve the initial admission roster and bootstrap endpoint from one relay +/// snapshot. A node start used to repeat the full membership + status query +/// for each value, making Share Compute startup both slower and more exposed +/// to inconsistent snapshots. +async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec, Option) { + match query_mesh_discovery_events(state).await { + Ok(events) => { + let trusted_owner_ids = mesh_llm::owner_ids_from_events(&events); + let join_token = mesh_llm::ensure_owner_identity() + .ok() + .and_then(|identity| { + buzz_mesh_join_targets( + mesh_llm::availability_from_events(events).serve_targets, + &identity.owner_id, + ) + .into_iter() + .next() + }) + .map(|target| target.endpoint_addr); + (trusted_owner_ids, join_token) + } + Err(error) => { + // Initial startup fails closed to this runtime's own owner. Share + // Compute must still start for the first member and through a + // transient relay outage; the coordinator retries convergence. + eprintln!( + "buzz-mesh: startup discovery failed; allowing only this node and starting isolated for now: {error}" + ); + (Vec::new(), None) + } + } +} + pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> CmdResult<()> { let Some(config) = load_mesh_sharing_config(app)? else { return Ok(()); @@ -144,6 +297,10 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C if !config.enabled || config.model_id.trim().is_empty() { return Ok(()); } + if state.mesh_llm_runtime.lock().await.is_some() { + return Ok(()); + } + let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(state).await; let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { return Ok(()); @@ -152,12 +309,13 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C mode: mesh_llm::MeshNodeMode::Serve, model_id: Some(config.model_id), max_vram_gb: config.max_vram_gb, - join_token: None, - trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await), + join_token, + mesh_name: Some(buzz_mesh_name(state)), + trusted_owner_ids: Some(trusted_owner_ids), }; let started = mesh_llm::DesktopMeshRuntime::start(request) .await - .map_err(|error| format!("failed to restore Share Compute: {error}"))?; + .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; *runtime = Some(started); drop(runtime); mesh_llm::publish_current_status_once(app, "restore").await; @@ -170,37 +328,91 @@ pub async fn mesh_start_node( state: State<'_, AppState>, mut request: mesh_llm::StartMeshNodeRequest, ) -> CmdResult { - // Frontend requests never carry a roster; resolve it here so every - // UI-started node enforces the member allowlist. - if request.trusted_owner_ids.is_none() { - request.trusted_owner_ids = Some(resolve_trusted_owner_ids_or_self_only(&state).await); + let sharing_config = if request.mode == mesh_llm::MeshNodeMode::Serve { + Some(sharing_config_from_request(&request)?) + } else { + None + }; + + // Never replace a client runtime in-process. Even a Ready SDK handle can + // finish `stop()` while its native listeners are still releasing :9337 + // and :3131; a pending client has no shutdown handle at all. Persist the + // requested serving configuration and switch roles across a controlled + // process restart, the only boundary that proves both ports are clean. + { + let runtime = state.mesh_llm_runtime.lock().await; + if let Some(existing) = runtime.as_ref() { + let plan = mesh_start_plan(request.mode, Some(existing.mode())); + match plan { + MeshStartPlan::RestartToReplaceClient => { + let config = sharing_config + .as_ref() + .ok_or_else(|| "serving configuration is unavailable".to_string())?; + drop(runtime); + return restart_to_share(&app, config); + } + MeshStartPlan::RejectOccupied => { + return Err("mesh node is already running".to_string()); + } + MeshStartPlan::Start => {} + } + } } + + // Frontend requests never carry a roster. Resolve it and the bootstrap + // endpoint from one snapshot so UI startup does not repeat relay probes. + if request.trusted_owner_ids.is_none() || request.join_token.is_none() { + let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(&state).await; + request.trusted_owner_ids.get_or_insert(trusted_owner_ids); + if request.join_token.is_none() { + request.join_token = join_token; + } + } + request.mesh_name = Some(buzz_mesh_name(&state)); let mut runtime = state.mesh_llm_runtime.lock().await; - if runtime.is_some() { + + let plan = match runtime.as_ref() { + Some(existing) => mesh_start_plan(request.mode, Some(existing.mode())), + None => mesh_start_plan(request.mode, None), + }; + if plan == MeshStartPlan::RestartToReplaceClient { + let config = sharing_config + .as_ref() + .ok_or_else(|| "serving configuration is unavailable".to_string())?; + drop(runtime); + return restart_to_share(&app, config); + } + if plan == MeshStartPlan::RejectOccupied { return Err("mesh node is already running".to_string()); } - let saved_request = request.clone(); let started = mesh_llm::DesktopMeshRuntime::start(request) .await - .map_err(|error| error.to_string())?; - let status = started - .status() - .await - .map_err(|error| format!("mesh node started but status probe failed: {error}"))?; + .map_err(|error| format!("{error:#}"))?; + let status = match started.status().await { + Ok(status) => status, + Err(error) => { + let cleanup = started.stop().await; + if let Err(cleanup_error) = &cleanup { + eprintln!( + "buzz-mesh: started node status failed and cleanup was incomplete: {cleanup_error:#}" + ); + } + // The handle was never installed into AppState, so shutdown cannot + // see it again. Restart even when stop reported success: the + // process boundary guarantees native :9337/:3131 listeners cannot + // linger behind an untracked runtime. + drop(runtime); + app.request_restart(); + return Err(format!( + "mesh node started but status probe failed: {error:#}; Buzz is restarting to guarantee cleanup" + )); + } + }; *runtime = Some(started); drop(runtime); - if saved_request.mode == mesh_llm::MeshNodeMode::Serve { - if let Some(model_id) = saved_request.model_id.as_deref() { - save_mesh_sharing_config( - &app, - &MeshSharingConfig { - enabled: true, - model_id: model_id.to_string(), - max_vram_gb: saved_request.max_vram_gb, - }, - )?; - } + if let Some(config) = sharing_config.as_ref() { + save_mesh_sharing_config(&app, config)?; } mesh_llm::publish_current_status_once(&app, "start").await; Ok(status) @@ -398,20 +610,29 @@ pub(crate) async fn ensure_client_node_for_model( mode: mesh_llm::MeshNodeMode::Client, model_id: None, max_vram_gb: None, - join_token: Some(join_token), + join_token: Some(join_token.clone()), + mesh_name: Some(buzz_mesh_name(state)), trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await), }; let mut runtime = state.mesh_llm_runtime.lock().await; - if runtime.is_some() { - return Err("mesh node changed while starting Buzz shared compute client".to_string()); + if let Some(existing) = runtime.as_ref() { + // Another GUI agent may have won the startup race while this caller + // was resolving membership. The runtime is machine-scoped, not + // agent-scoped: join its selected endpoint into the existing node and + // let every caller reuse the same local ingress. + existing + .dial_endpoint_addr(join_token) + .await + .map_err(|error| format!("mesh dial failed: {error}"))?; + return existing.status().await.map_err(|error| error.to_string()); } let started = mesh_llm::DesktopMeshRuntime::start(start) .await - .map_err(|error| format!("mesh client failed to start: {error}"))?; + .map_err(|error| format!("mesh client failed to start: {error:#}"))?; let status = started .status() .await - .map_err(|error| format!("mesh client started but status probe failed: {error}"))?; + .map_err(|error| format!("mesh client started but status probe failed: {error:#}"))?; *runtime = Some(started); Ok(status) } @@ -481,20 +702,58 @@ fn pick_serve_target_for_model( /// Non relay-mesh records are a no-op. pub(crate) async fn ensure_relay_mesh_for_record( app: &AppHandle, - record: &crate::managed_agents::ManagedAgentRecord, + model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { let state = app.state::(); - let Some(model_id) = crate::managed_agents::relay_mesh_model_id(record) else { + let Some(model_id) = model_id else { return Ok(()); }; // A local serve/client runtime already owns the OpenAI ingress and its // router can resolve both `auto` and explicit remote models. Do not require - // a separate relay-advertised target in that case. + // a separate relay-advertised target in that case — BUT only trust it when + // the ingress is actually alive. A runtime that exited/wedged after launch + // leaves `mesh_llm_runtime = Some` pointing at a dead `:9337` ingress, so a + // blind `wait_for_mesh_inference` would just time out and the agent would + // stay silent (#2062). Probe first; if the ingress is dead, drop the stale + // runtime and fall through to re-arm it. The mesh coordinator watchdog also + // calls this path after eviction so recovery is not start-only (Brad #2304). if state.mesh_llm_runtime.lock().await.is_some() { - return wait_for_mesh_inference(&model_id).await; + match mesh_llm::recover_stale_mesh_runtime( + &state, + mesh_llm::MeshRecoveryUrgency::Foreground, + ) + .await + { + mesh_llm::MeshRuntimeRecovery::Live => { + return wait_for_mesh_inference(model_id).await; + } + mesh_llm::MeshRuntimeRecovery::Evicted | mesh_llm::MeshRuntimeRecovery::Absent => {} + mesh_llm::MeshRuntimeRecovery::Debouncing => { + return Err( + "Buzz shared compute ingress is temporarily unresponsive; recovery is already scheduled. Try again shortly." + .to_string(), + ); + } + mesh_llm::MeshRuntimeRecovery::ReleasePending => { + return Err( + "Buzz shared compute is still shutting down its previous local ingress. Try again shortly." + .to_string(), + ); + } + mesh_llm::MeshRuntimeRecovery::Replaced => { + return wait_for_mesh_inference(model_id).await; + } + mesh_llm::MeshRuntimeRecovery::RestartRequired => { + app.request_restart(); + return Err( + "Buzz shared compute startup lost its local ingress before shutdown control became available. Buzz is restarting to recover it." + .to_string(), + ); + } + } } - let target = match resolve_mesh_bootstrap_target(&state, &model_id).await { + let target = match resolve_mesh_bootstrap_target(&state, model_id).await { Ok(Some(target)) => target, Ok(None) => { return Err( @@ -509,8 +768,17 @@ pub(crate) async fn ensure_relay_mesh_for_record( } }; - ensure_client_node_for_model(&state, &model_id, Some(target.endpoint_addr)).await?; - wait_for_mesh_inference(&model_id).await + // Serve→Client re-arm transition (micspiral review #3, intentional-by-design): + // if the dead ingress belonged to a *serve* node with running consumer + // agents, this re-arms it as a Client (`MeshNodeMode::Client`). That is the + // correct/safe recovery here — config-backed serve restoration is + // `restore_mesh_sharing`'s job (`MeshNodeMode::Serve`), and + // `ensure_client_node_for_model` reuses any live runtime of *either* mode + // (the router resolves per-request), so it only cold-starts a Client when + // there is genuinely no runtime. Falling back to Client if a serve node + // crashed under local pressure is a desirable fail-safe, not a regression. + ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; + wait_for_mesh_inference(model_id).await } #[tauri::command] @@ -597,266 +865,5 @@ pub async fn mesh_model_catalog() -> CmdResult { } #[cfg(all(test, feature = "mesh-llm"))] -mod tests { - use super::*; - use crate::app_state::build_app_state; - - fn target(model_id: &str, endpoint_addr: &str) -> mesh_llm::MeshServeTarget { - mesh_llm::MeshServeTarget { - model_id: model_id.to_string(), - model_name: None, - endpoint_addr: endpoint_addr.to_string(), - node_name: None, - capacity: None, - endpoint_id: None, - device_id: None, - device_name: None, - } - } - - #[test] - fn readiness_failure_is_catalog_sync_when_model_never_visible() { - assert_eq!( - classify_mesh_readiness_failure(false), - MeshReadinessFailure::CatalogNeverSynced - ); - } - - #[test] - fn readiness_failure_is_routing_when_model_was_visible() { - assert_eq!( - classify_mesh_readiness_failure(true), - MeshReadinessFailure::RoutingNeverCompleted - ); - } - - #[test] - fn readiness_messages_are_distinct_and_actionable() { - let catalog = mesh_readiness_failure_message( - MeshReadinessFailure::CatalogNeverSynced, - "auto", - "HTTP 429", - ); - let routing = mesh_readiness_failure_message( - MeshReadinessFailure::RoutingNeverCompleted, - "auto", - "HTTP 503", - ); - // Distinct diagnoses, each names the model and carries the raw detail. - assert_ne!(catalog, routing); - assert!(catalog.contains("network path")); - assert!(catalog.contains("HTTP 429")); - assert!(routing.contains("did not complete")); - assert!(routing.contains("HTTP 503")); - } - - #[test] - fn mesh_status_cursor_uses_relay_composite_tiebreak() { - let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status") - .custom_created_at(nostr::Timestamp::from(1_234)) - .sign_with_keys(&nostr::Keys::generate()) - .expect("sign test status"); - let mut filter = mesh_llm::mesh_status_filter(); - - let cursor = advance_mesh_status_cursor(&mut filter, std::slice::from_ref(&event)) - .expect("advance status cursor"); - - assert_eq!(cursor, (1_234, event.id.to_hex())); - assert_eq!(filter["until"], serde_json::json!(1_234)); - assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex())); - assert_eq!( - filter["limit"], - serde_json::json!(mesh_llm::MESH_STATUS_PAGE_SIZE) - ); - } - - #[test] - fn pick_serve_target_returns_first_match_for_model() { - let targets = vec![ - target("model-a", "addr-a"), - target("model-b", "addr-b1"), - target("model-b", "addr-b2"), - ]; - // Matches by model id and returns the first such target. - assert_eq!( - pick_serve_target_for_model(targets, "model-b").map(|t| t.endpoint_addr), - Some("addr-b1".to_string()) - ); - } - - #[test] - fn pick_serve_target_normalizes_main_revision() { - let targets = vec![target("org/model@main:q4", "addr")]; - assert_eq!( - pick_serve_target_for_model(targets, "org/model:q4").map(|target| target.endpoint_addr), - Some("addr".to_string()) - ); - } - - #[test] - fn pick_serve_target_auto_takes_any_live_target() { - let targets = vec![target("model-a", "addr-a"), target("model-b", "addr-b")]; - // "auto" delegates model choice to the mesh router; any live target - // is a valid bootstrap peer (first one wins). - assert_eq!( - pick_serve_target_for_model(targets, crate::mesh_llm::AUTO_MODEL_ID) - .map(|t| t.endpoint_addr), - Some("addr-a".to_string()) - ); - // But auto with zero live targets still falls closed. - assert_eq!( - pick_serve_target_for_model(Vec::new(), crate::mesh_llm::AUTO_MODEL_ID), - None - ); - } - - #[test] - fn pick_serve_target_none_when_model_not_hosted() { - let targets = vec![target("model-a", "addr-a")]; - // No live target serves this model -> caller falls closed. - assert_eq!(pick_serve_target_for_model(targets, "model-missing"), None); - } - - #[test] - fn share_stop_tears_down_serve_but_not_client() { - // Stopping "Share compute" tears down a serve node (we were sharing) - // but must leave a client node alone (we are consuming a peer). This is - // the backend half of the toggle-on regression: a client node occupies - // the single slot and reports state:"running", and the stop path must - // not kill it. - assert!( - share_stop_should_teardown(mesh_llm::MeshNodeMode::Serve), - "serve node is our sharing runtime; stop must tear it down" - ); - assert!( - !share_stop_should_teardown(mesh_llm::MeshNodeMode::Client), - "client node is a consume session; stop must NOT tear it down" - ); - } - - #[test] - fn client_status_serializes_with_running_state_and_client_mode() { - // Contract pin for the TS mock (e2eBridge.ts) and the frontend - // predicate: a consuming node serializes as - // {"state":"running","mode":"client"}. If serde renaming drifts, the - // hand-written mock shape and `deriveMeshShareToggle` would silently - // stop matching the real IPC payload. - let status = mesh_llm::MeshNodeStatus { - state: mesh_llm::MeshNodeState::Running, - mode: Some(mesh_llm::MeshNodeMode::Client), - // `MeshHealth::ok()` is module-private; build via the public fields. - health: mesh_llm::MeshHealth { - status: mesh_llm::MeshHealthStatus::Ok, - reason: None, - }, - api_base_url: Some("http://127.0.0.1:9337/v1".to_string()), - console_url: None, - model_id: None, - model_name: None, - invite_token: None, - endpoint_id: None, - device_id: None, - device_name: None, - }; - let value = serde_json::to_value(&status).expect("serialize mesh status"); - assert_eq!(value["state"], serde_json::json!("running")); - assert_eq!(value["mode"], serde_json::json!("client")); - } - - #[tokio::test] - async fn cold_client_preflight_requires_explicit_target() { - let state = build_app_state(); - let error = ensure_client_node_for_model(&state, "demo/model", None) - .await - .expect_err("cold relay-mesh preflight must not auto-pick a target"); - assert_eq!(error, RELAY_MESH_RUNTIME_NO_TARGET); - } - - /// Acceptance-critical regression for dropping the serve-vs-client guard. - /// - /// Before this change, `ensure_client_node_for_model` hard-errored whenever - /// the running runtime was in `Serve` mode ("stop sharing before using - /// Buzz shared compute as a client"). That forbade exactly what a user should be - /// able to do: host model A while pointing an agent at a different model B - /// through the same `9337` ingress. - /// - /// This test starts a real serve runtime and asserts that a follow-up - /// preflight for a *different* model and no explicit target still reuses the - /// existing runtime. Cold starts without a target are rejected before mesh-llm - /// startup; running runtimes are already joined to whatever target the - /// frontend selected earlier. - /// - /// Hardware-gated (`#[ignore]`): loads a real model. Run with: - /// cargo test -p buzz-desktop --features mesh-llm \ - /// ensure_serve_runtime_serves_other_model -- --ignored --nocapture - #[test] - #[ignore = "loads a real model; run manually with --ignored"] - fn ensure_serve_runtime_serves_other_model() { - std::thread::Builder::new() - .name("mesh-hardware-acceptance".to_string()) - .stack_size(mesh_llm::MESH_WORKER_STACK_SIZE) - .spawn(|| { - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .thread_stack_size(mesh_llm::MESH_WORKER_STACK_SIZE) - .enable_all() - .build() - .expect("build mesh acceptance runtime"); - runtime.block_on(async { - const HOSTED_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; - const OTHER_MODEL: &str = "some/other-model-not-hosted-locally:Q4_K_M"; - - let state = build_app_state(); - - // Start a serve runtime hosting HOSTED_MODEL — this is the "Share - // compute" path. - let serve = - mesh_llm::DesktopMeshRuntime::start(mesh_llm::StartMeshNodeRequest { - mode: mesh_llm::MeshNodeMode::Serve, - model_id: Some(HOSTED_MODEL.to_string()), - max_vram_gb: None, - join_token: None, - trusted_owner_ids: None, - }) - .await - .expect("serve runtime should start"); - - let serve_status = serve.status().await.expect("serve status"); - let serve_base = serve_status.api_base_url.clone(); - assert_eq!(serve_status.mode, Some(mesh_llm::MeshNodeMode::Serve)); - - { - let mut runtime = state.mesh_llm_runtime.lock().await; - *runtime = Some(serve); - } - - // Preflight for a DIFFERENT model with no explicit target. Old code: - // Err(...sharing compute...). New code: reuse the running ingress. - let status = ensure_client_node_for_model(&state, OTHER_MODEL, None) - .await - .expect("serve runtime must not reject a different-model preflight"); - - // It returns the SAME running node — agents keep using A's 9337, and - // the router decides routability for OTHER_MODEL per request. - assert_eq!( - status.mode, - Some(mesh_llm::MeshNodeMode::Serve), - "preflight should reuse the existing serve runtime, not spin up a client" - ); - assert_eq!( - status.api_base_url, serve_base, - "agent must be pointed at the existing serve node's ingress" - ); - - // Clean up the runtime. - let taken = state.mesh_llm_runtime.lock().await.take(); - if let Some(runtime) = taken { - let _ = runtime.stop().await; - } - }); - }) - .expect("spawn mesh acceptance thread") - .join() - .expect("mesh acceptance thread panicked"); - } -} +#[path = "mesh_llm_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs new file mode 100644 index 0000000000..ccc5287d62 --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -0,0 +1,499 @@ +use super::*; +use crate::app_state::build_app_state; + +fn target(model_id: &str, endpoint_addr: &str) -> mesh_llm::MeshServeTarget { + mesh_llm::MeshServeTarget { + model_id: model_id.to_string(), + model_name: None, + endpoint_addr: endpoint_addr.to_string(), + reporter_pubkey: None, + owner_id: None, + node_name: None, + capacity: None, + endpoint_id: None, + device_id: None, + device_name: None, + } +} + +fn reported_target( + reporter_pubkey: &str, + model_id: &str, + endpoint_addr: &str, +) -> mesh_llm::MeshServeTarget { + let mut target = target(model_id, endpoint_addr); + target.reporter_pubkey = Some(reporter_pubkey.to_string()); + target.owner_id = Some(reporter_pubkey.to_string()); + target +} + +#[test] +fn buzz_mesh_join_uses_the_same_live_member_from_every_other_node() { + let targets = vec![ + reported_target("member-c", "model-c", "addr-c"), + reported_target("member-a", "model-a", "addr-a"), + reported_target("member-b", "model-b", "addr-b"), + ]; + + assert_eq!( + buzz_mesh_join_targets(targets.clone(), "member-b") + .into_iter() + .next() + .map(|target| target.endpoint_addr), + Some("addr-a".to_string()) + ); + assert_eq!( + buzz_mesh_join_targets(targets, "member-c") + .into_iter() + .next() + .map(|target| target.endpoint_addr), + Some("addr-a".to_string()) + ); +} + +#[test] +fn buzz_mesh_bootstrap_member_does_not_dial_itself() { + let targets = vec![ + reported_target("member-b", "model-b", "addr-b"), + reported_target("member-a", "model-a", "addr-a"), + ]; + + assert_eq!( + buzz_mesh_join_targets(targets, "MEMBER-A") + .into_iter() + .next(), + Some(reported_target("member-b", "model-b", "addr-b")) + ); +} + +#[test] +fn buzz_mesh_join_ignores_targets_without_a_validated_reporter() { + let targets = vec![ + target("unbound-model", "unbound-addr"), + reported_target("member-b", "model-b", "addr-b"), + ]; + + assert_eq!( + buzz_mesh_join_targets(targets, "member-c") + .into_iter() + .next() + .map(|target| target.endpoint_addr), + Some("addr-b".to_string()) + ); +} + +#[test] +fn buzz_mesh_join_keeps_other_device_with_the_same_member_key() { + let mut self_target = reported_target("same-member", "model-a", "self-addr"); + self_target.owner_id = Some("owner-self".to_string()); + let mut other_device = reported_target("same-member", "model-b", "other-addr"); + other_device.owner_id = Some("owner-other".to_string()); + + assert_eq!( + buzz_mesh_join_targets(vec![self_target, other_device], "owner-self") + .into_iter() + .next() + .map(|target| target.endpoint_addr), + Some("other-addr".to_string()) + ); +} + +#[test] +fn buzz_mesh_name_is_stable_and_does_not_expose_the_relay() { + let first = buzz_mesh_name_for_relay("WSS://EXAMPLE.COM/"); + let second = buzz_mesh_name_for_relay("wss://example.com:443/some/path?ignored=yes"); + let other_relay = buzz_mesh_name_for_relay("wss://other.example.com"); + + assert_eq!(first, second); + assert_ne!(first, other_relay); + assert!(first.starts_with("buzz-community-")); + assert!(!first.contains("example")); +} + +#[test] +fn readiness_failure_is_catalog_sync_when_model_never_visible() { + assert_eq!( + classify_mesh_readiness_failure(false), + MeshReadinessFailure::CatalogNeverSynced + ); +} + +#[test] +fn readiness_failure_is_routing_when_model_was_visible() { + assert_eq!( + classify_mesh_readiness_failure(true), + MeshReadinessFailure::RoutingNeverCompleted + ); +} + +#[test] +fn readiness_messages_are_distinct_and_actionable() { + let catalog = mesh_readiness_failure_message( + MeshReadinessFailure::CatalogNeverSynced, + "auto", + "HTTP 429", + ); + let routing = mesh_readiness_failure_message( + MeshReadinessFailure::RoutingNeverCompleted, + "auto", + "HTTP 503", + ); + // Distinct diagnoses, each names the model and carries the raw detail. + assert_ne!(catalog, routing); + assert!(catalog.contains("network path")); + assert!(catalog.contains("HTTP 429")); + assert!(routing.contains("did not complete")); + assert!(routing.contains("HTTP 503")); +} + +#[test] +fn mesh_status_cursor_uses_relay_composite_tiebreak() { + let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status") + .custom_created_at(nostr::Timestamp::from(1_234)) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign test status"); + let mut filter = mesh_llm::mesh_status_filter(); + + let cursor = advance_mesh_status_cursor(&mut filter, std::slice::from_ref(&event)) + .expect("advance status cursor"); + + assert_eq!(cursor, (1_234, event.id.to_hex())); + assert_eq!(filter["until"], serde_json::json!(1_234)); + assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex())); + assert_eq!( + filter["limit"], + serde_json::json!(mesh_llm::MESH_STATUS_PAGE_SIZE) + ); +} + +#[test] +fn pick_serve_target_returns_first_match_for_model() { + let targets = vec![ + target("model-a", "addr-a"), + target("model-b", "addr-b1"), + target("model-b", "addr-b2"), + ]; + // Matches by model id and returns the first such target. + assert_eq!( + pick_serve_target_for_model(targets, "model-b").map(|t| t.endpoint_addr), + Some("addr-b1".to_string()) + ); +} + +#[test] +fn pick_serve_target_normalizes_main_revision() { + let targets = vec![target("org/model@main:q4", "addr")]; + assert_eq!( + pick_serve_target_for_model(targets, "org/model:q4").map(|target| target.endpoint_addr), + Some("addr".to_string()) + ); +} + +#[test] +fn pick_serve_target_auto_takes_any_live_target() { + let targets = vec![target("model-a", "addr-a"), target("model-b", "addr-b")]; + // "auto" delegates model choice to the mesh router; any live target + // is a valid bootstrap peer (first one wins). + assert_eq!( + pick_serve_target_for_model(targets, crate::mesh_llm::AUTO_MODEL_ID) + .map(|t| t.endpoint_addr), + Some("addr-a".to_string()) + ); + // But auto with zero live targets still falls closed. + assert_eq!( + pick_serve_target_for_model(Vec::new(), crate::mesh_llm::AUTO_MODEL_ID), + None + ); +} + +#[test] +fn pick_serve_target_none_when_model_not_hosted() { + let targets = vec![target("model-a", "addr-a")]; + // No live target serves this model -> caller falls closed. + assert_eq!(pick_serve_target_for_model(targets, "model-missing"), None); +} + +#[test] +fn share_stop_tears_down_serve_but_not_client() { + // Stopping "Share compute" tears down a serve node (we were sharing) + // but must leave a client node alone (we are consuming a peer). This is + // the backend half of the toggle-on regression: a client node occupies + // the single slot and reports state:"running", and the stop path must + // not kill it. + assert!( + share_stop_should_teardown(mesh_llm::MeshNodeMode::Serve), + "serve node is our sharing runtime; stop must tear it down" + ); + assert!( + !share_stop_should_teardown(mesh_llm::MeshNodeMode::Client), + "client node is a consume session; stop must NOT tear it down" + ); +} + +#[test] +fn share_start_restarts_to_replace_only_client_runtimes() { + assert_eq!( + mesh_start_plan(mesh_llm::MeshNodeMode::Serve, None), + MeshStartPlan::Start + ); + assert_eq!( + mesh_start_plan( + mesh_llm::MeshNodeMode::Serve, + Some(mesh_llm::MeshNodeMode::Client), + ), + MeshStartPlan::RestartToReplaceClient + ); + assert_eq!( + mesh_start_plan( + mesh_llm::MeshNodeMode::Serve, + Some(mesh_llm::MeshNodeMode::Serve), + ), + MeshStartPlan::RejectOccupied + ); + assert_eq!( + mesh_start_plan( + mesh_llm::MeshNodeMode::Client, + Some(mesh_llm::MeshNodeMode::Client), + ), + MeshStartPlan::RejectOccupied + ); +} + +#[test] +fn client_status_serializes_with_running_state_and_client_mode() { + // Contract pin for the TS mock (e2eBridge.ts) and the frontend + // predicate: a consuming node serializes as + // {"state":"running","mode":"client"}. If serde renaming drifts, the + // hand-written mock shape and `deriveMeshShareToggle` would silently + // stop matching the real IPC payload. + let status = mesh_llm::MeshNodeStatus { + state: mesh_llm::MeshNodeState::Running, + mode: Some(mesh_llm::MeshNodeMode::Client), + // `MeshHealth::ok()` is module-private; build via the public fields. + health: mesh_llm::MeshHealth { + status: mesh_llm::MeshHealthStatus::Ok, + reason: None, + }, + api_base_url: Some("http://127.0.0.1:9337/v1".to_string()), + console_url: None, + model_id: None, + model_name: None, + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + }; + let value = serde_json::to_value(&status).expect("serialize mesh status"); + assert_eq!(value["state"], serde_json::json!("running")); + assert_eq!(value["mode"], serde_json::json!("client")); +} + +#[tokio::test] +async fn cold_client_preflight_requires_explicit_target() { + let state = build_app_state(); + let error = ensure_client_node_for_model(&state, "demo/model", None) + .await + .expect_err("cold relay-mesh preflight must not auto-pick a target"); + assert_eq!(error, RELAY_MESH_RUNTIME_NO_TARGET); +} + +/// Acceptance-critical regression for dropping the serve-vs-client guard. +/// +/// Before this change, `ensure_client_node_for_model` hard-errored whenever +/// the running runtime was in `Serve` mode ("stop sharing before using +/// Buzz shared compute as a client"). That forbade exactly what a user should be +/// able to do: host model A while pointing an agent at a different model B +/// through the same `9337` ingress. +/// +/// This test starts a real serve runtime and asserts that a follow-up +/// preflight for a *different* model and no explicit target still reuses the +/// existing runtime. Cold starts without a target are rejected before mesh-llm +/// startup; running runtimes are already joined to whatever target the +/// frontend selected earlier. +/// +/// Hardware-gated (`#[ignore]`): loads a real model. Run with: +/// cargo test -p buzz-desktop --features mesh-llm \ +/// ensure_serve_runtime_serves_other_model -- --ignored --nocapture +#[test] +#[ignore = "loads a real model; run manually with --ignored"] +fn ensure_serve_runtime_serves_other_model() { + std::thread::Builder::new() + .name("mesh-hardware-acceptance".to_string()) + .stack_size(mesh_llm::MESH_WORKER_STACK_SIZE) + .spawn(|| { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(mesh_llm::MESH_WORKER_STACK_SIZE) + .enable_all() + .build() + .expect("build mesh acceptance runtime"); + runtime.block_on(async { + const DEFAULT_HOSTED_MODEL: &str = + "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; + const OTHER_MODEL: &str = "some/other-model-not-hosted-locally:Q4_K_M"; + let hosted_model = std::env::var("BUZZ_MESH_TEST_MODEL") + .unwrap_or_else(|_| DEFAULT_HOSTED_MODEL.to_string()); + + let state = build_app_state(); + + // Start a serve runtime hosting HOSTED_MODEL — this is the "Share + // compute" path. + let serve = + mesh_llm::DesktopMeshRuntime::start(mesh_llm::StartMeshNodeRequest { + mode: mesh_llm::MeshNodeMode::Serve, + model_id: Some(hosted_model.clone()), + max_vram_gb: None, + join_token: None, + mesh_name: None, + trusted_owner_ids: None, + }) + .await + .expect("serve runtime should start"); + + let serve_status = serve.status().await.expect("serve status"); + let serve_base = serve_status + .api_base_url + .clone() + .expect("serve runtime must expose its local API base"); + assert_eq!(serve_status.mode, Some(mesh_llm::MeshNodeMode::Serve)); + + { + let mut runtime = state.mesh_llm_runtime.lock().await; + *runtime = Some(serve); + } + + // Concurrent GUI agent starts all reuse this one machine-scoped + // runtime. None may create a per-agent client/serve node. + let preflights = tokio::join!( + ensure_client_node_for_model(&state, OTHER_MODEL, None), + ensure_client_node_for_model(&state, OTHER_MODEL, None), + ensure_client_node_for_model(&state, OTHER_MODEL, None), + ensure_client_node_for_model(&state, OTHER_MODEL, None), + ); + let statuses = [ + preflights.0, + preflights.1, + preflights.2, + preflights.3, + ] + .into_iter() + .collect::, _>>() + .expect("all agent preflights must reuse the serve runtime"); + + // It returns the SAME running node — agents keep using A's 9337, and + // the router decides routability for OTHER_MODEL per request. + for status in statuses { + assert_eq!( + status.mode, + Some(mesh_llm::MeshNodeMode::Serve), + "preflight should reuse the existing serve runtime, not spin up a client" + ); + assert_eq!( + status.api_base_url.as_deref(), + Some(serve_base.as_str()), + "every agent must be pointed at the machine's existing ingress" + ); + } + + // A standalone Share Compute runtime must advertise exactly + // one physical model and serve inference through `auto`. + let http = reqwest::Client::new(); + let catalog_deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(120); + let catalog = loop { + let body = http + .get(format!("{serve_base}/models")) + .send() + .await + .expect("query single-node catalog") + .error_for_status() + .expect("single-node catalog status") + .json::() + .await + .expect("parse single-node catalog"); + let physical_count = body["data"] + .as_array() + .map(|models| { + models + .iter() + .filter_map(|model| model["id"].as_str()) + .filter(|model| *model != "mesh") + .count() + }) + .unwrap_or_default(); + if physical_count > 0 { + break body; + } + assert!( + tokio::time::Instant::now() < catalog_deadline, + "single Share Compute model never became ready: {body}" + ); + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + }; + let physical_models = catalog["data"] + .as_array() + .expect("catalog data") + .iter() + .filter_map(|model| model["id"].as_str()) + .filter(|model| *model != "mesh") + .collect::>(); + assert_eq!( + physical_models.len(), + 1, + "single Share Compute node must advertise one physical model: {catalog}" + ); + assert!( + physical_models[0].contains(hosted_model.split(':').next().unwrap_or("")), + "catalog must contain the hosted model: {catalog}" + ); + + let inference_deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(120); + let inference = loop { + let response = http + .post(format!("{serve_base}/chat/completions")) + .json(&serde_json::json!({ + "model": "auto", + "messages": [{ + "role": "user", + "content": "Reply with exactly BUZZ_SINGLE_SHARE_OK and nothing else." + }], + "max_tokens": 512, + "temperature": 0 + })) + .send() + .await + .expect("single-node auto inference"); + if response.status().is_success() { + break response + .json::() + .await + .expect("parse single-node inference"); + } + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert!( + tokio::time::Instant::now() < inference_deadline, + "single-node auto inference never became ready: HTTP {status}: {body}" + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + }; + let answer = inference["choices"][0]["message"]["content"] + .as_str() + .unwrap_or_default(); + assert!( + !answer.trim().is_empty(), + "single-node auto must produce visible output: {inference}" + ); + + // Clean up the runtime. + let taken = state.mesh_llm_runtime.lock().await.take(); + if let Some(runtime) = taken { + let _ = runtime.stop().await; + } + }); + }) + .expect("spawn mesh acceptance thread") + .join() + .expect("mesh acceptance thread panicked"); +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1928d69d23..b7c37bec3d 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -68,7 +68,20 @@ pub async fn get_feed( // Mentions: messages that reference me via #p. let mut mention_filter = serde_json::json!({ - "kinds": [9, 40002, 1, 45001, 45003], + "kinds": [ + 9, + 40002, + 1, + 45001, + 45003, + buzz_core_pkg::kind::KIND_GIT_PULL_REQUEST, + buzz_core_pkg::kind::KIND_GIT_PR_UPDATE, + buzz_core_pkg::kind::KIND_GIT_ISSUE, + buzz_core_pkg::kind::KIND_GIT_STATUS_OPEN, + buzz_core_pkg::kind::KIND_GIT_STATUS_MERGED, + buzz_core_pkg::kind::KIND_GIT_STATUS_CLOSED, + buzz_core_pkg::kind::KIND_GIT_STATUS_DRAFT, + ], "#p": [my_pubkey], "limit": cap, }); @@ -125,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(), @@ -140,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) } @@ -148,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 3c08f0f113..a907a3dff1 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 7e9d916be6..1c89ee4f77 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ mod agent_logs; mod agent_metric_archive; mod agent_model_process; mod agent_models; +mod agent_models_env; mod agent_providers; mod agent_settings; mod agent_update_rollback; @@ -20,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; @@ -77,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/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 639ae16da3..fc874a0150 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -35,6 +36,7 @@ struct PairingErrorPayload { /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, + generation: Arc, cancel: std::sync::Mutex>, /// Send JSON-serialized events to the background WS task for relay publication. outbound_tx: std::sync::Mutex>>, @@ -47,6 +49,7 @@ impl PairingHandle { pub fn new() -> Self { Self { session: Arc::new(tokio::sync::Mutex::new(None)), + generation: Arc::new(AtomicU64::new(0)), cancel: std::sync::Mutex::new(None), outbound_tx: std::sync::Mutex::new(None), payload: std::sync::Mutex::new(None), @@ -71,10 +74,18 @@ pub async fn start_pairing( state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { + let task_generation = pairing + .generation + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1); if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } pairing.clear(); + { + let mut session = pairing.session.lock().await; + *session = None; + } let keys = state.signing_keys()?; let nsec = keys @@ -117,9 +128,12 @@ pub async fn start_pairing( *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); let session_arc = Arc::clone(&pairing.session); + let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( pairing_relay_url, session_arc, + generation, + task_generation, cancel, outbound_rx, app, @@ -199,6 +213,8 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } + pairing.generation.fetch_add(1, Ordering::SeqCst); + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -215,22 +231,35 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str async fn pairing_ws_task( relay_url: String, session: Arc>>, + generation: Arc, + task_generation: u64, cancel: CancellationToken, mut outbound_rx: mpsc::Receiver, app: AppHandle, ) { - if let Err(e) = - pairing_ws_task_inner(&relay_url, &session, &cancel, &mut outbound_rx, &app).await + if let Err(e) = pairing_ws_task_inner( + &relay_url, + &session, + &generation, + task_generation, + &cancel, + &mut outbound_rx, + &app, + ) + .await { - let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + if pairing_task_is_current(&generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + } } - let mut s = session.lock().await; - *s = None; + clear_pairing_session_if_current(&session, &generation, task_generation).await; } async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, cancel: &CancellationToken, outbound_rx: &mut mpsc::Receiver, app: &AppHandle, @@ -261,12 +290,18 @@ async fn pairing_ws_task_inner( tokio::pin!(hard_timeout); loop { + if !pairing_task_is_current(generation, task_generation) { + break; + } + tokio::select! { _ = cancel.cancelled() => break, _ = &mut hard_timeout => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Session timed out".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Session timed out".into(), + }); + } break; } Some(json_msg) = outbound_rx.recv() => { @@ -282,30 +317,42 @@ async fn pairing_ws_task_inner( let Message::Text(text) = msg else { continue }; if let Some(event) = parse_relay_event(text.as_str(), "pair") { + if !pairing_task_is_current(generation, task_generation) { + break; + } + let mut guard = session.lock().await; let Some(s) = guard.as_mut() else { break }; if let Ok(reason) = s.handle_abort(&event) { - let _ = app.emit("pairing-aborted", PairingAbortedPayload { - reason: format!("{reason:?}"), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-aborted", PairingAbortedPayload { + reason: format!("{reason:?}"), + }); + } break; } if let Ok(sas) = s.handle_offer(&event) { - let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + } continue; } match s.handle_complete(&event) { Ok(()) => { - let _ = app.emit("pairing-complete", serde_json::json!({})); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } break; } Err(ref e) if format!("{e}").contains("success=false") => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Mobile device reported failure importing credentials".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + } break; } Err(_) => {} @@ -318,6 +365,21 @@ async fn pairing_ws_task_inner( Ok(()) } +fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { + generation.load(Ordering::SeqCst) == task_generation +} + +async fn clear_pairing_session_if_current( + session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, +) { + let mut session = session.lock().await; + if pairing_task_is_current(generation, task_generation) { + *session = None; + } +} + async fn handle_nip42_auth( read: &mut R, write: &mut W, @@ -527,6 +589,40 @@ where .map_err(|_| "timeout waiting for EOSE".to_string())? } +#[cfg(test)] +mod pairing_generation_tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + use super::{clear_pairing_session_if_current, PairingSession}; + + #[tokio::test] + async fn stale_task_does_not_clear_replacement_session() { + let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); + let generation = AtomicU64::new(1); + + generation.store(2, Ordering::SeqCst); + let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); + *session.lock().await = Some(replacement); + + clear_pairing_session_if_current(&session, &generation, 1).await; + + assert!(session.lock().await.is_some()); + } + + #[tokio::test] + async fn current_task_clears_its_session() { + let (active, _) = PairingSession::new_source("ws://active.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(active))); + let generation = AtomicU64::new(3); + + clear_pairing_session_if_current(&session, &generation, 3).await; + + assert!(session.lock().await.is_none()); + } +} + #[cfg(test)] mod pairing_relay_tests { use super::{ 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 0000000000..c00de1c6da --- /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 316af5f72d..8ff7cfbd9b 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 0000000000..d7ffecef2d --- /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 1000e48b70..1005a83432 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 6437d4b1a8..66f7296a25 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 4d887ca39e..a4003329bc 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 0000000000..914c56252d --- /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 e4d8a5d1bc..583296dac0 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 0000000000..00a1457393 --- /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 ac5c0eace6..d23efe7730 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 b1d19f06b6..4289310280 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 0000000000..ed2472d54e --- /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 ba855ccbd6..c60215ae4d 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_diff.rs b/desktop/src-tauri/src/commands/project_git_diff.rs index 0bd07cbffd..400b48eaa5 100644 --- a/desktop/src-tauri/src/commands/project_git_diff.rs +++ b/desktop/src-tauri/src/commands/project_git_diff.rs @@ -25,6 +25,7 @@ pub struct ProjectRepoDiffInfo { pub files: Vec, pub additions: usize, pub deletions: usize, + pub commit_body: Option, } fn clean_target_ref(value: Option) -> Option { @@ -340,7 +341,25 @@ fn diff_from_repo( repo_dir: &std::path::Path, auth: &GitAuthConfig, range: &str, + target_commit: Option<&str>, ) -> Result { + let commit_body = target_commit + .map(|commit| { + run_git( + &[ + "show", + "--no-patch", + "--format=%b", + "--end-of-options", + commit, + ], + Some(repo_dir), + auth, + ) + .map(|body| body.trim_end().to_string()) + }) + .transpose()? + .filter(|body| !body.is_empty()); let numstat = run_git(&["diff", "--numstat", range], Some(repo_dir), auth)?; let files = parse_numstat(&numstat) .into_iter() @@ -375,6 +394,7 @@ fn diff_from_repo( Ok(ProjectRepoDiffInfo { additions: files.iter().map(|file| file.additions).sum(), deletions: files.iter().map(|file| file.deletions).sum(), + commit_body, files, }) } @@ -430,7 +450,12 @@ pub async fn get_project_repo_diff( diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), ), }; - diff_from_repo(&repo_dir, &auth, &range) + let commit_body_ref = if target_ref.is_none() && base_branch.is_none() { + target_commit.as_deref() + } else { + None + }; + diff_from_repo(&repo_dir, &auth, &range, commit_body_ref) }) .await .map_err(|error| format!("repo diff task failed: {error}"))? @@ -468,7 +493,12 @@ pub async fn get_project_local_repo_diff( base_commit.as_deref(), target_commit.as_deref(), ); - diff_from_repo(&repo_dir, &auth, &range).map(Some) + let commit_body_ref = if base_commit.is_none() && base_branch.is_none() { + target_commit.as_deref() + } else { + None + }; + diff_from_repo(&repo_dir, &auth, &range, commit_body_ref).map(Some) }) .await .map_err(|error| format!("local repo diff task failed: {error}"))? diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index 186c1a1cf6..e4a8ad7b41 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 0476be79a9..91a0126f58 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 ca7dc61830..0616411307 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 ea9a6a4958..4377ddaa43 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 561e901998..731a99d9d9 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 2ec5aa1c0e..d9fe6acdb9 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 @@ -438,6 +430,21 @@ pub fn run() { eprintln!("buzz-desktop: persona-snapshot backfill failed: {e}"); } + // Warm the loaded-harness registry BEFORE restore so cold-launch + // agent spawns can resolve custom/preset runtime ids without + // waiting for the frontend's discover_acp_providers call. This is + // a pure directory scan — no PATH probing, no async work. + { + let custom_dir = app_handle + .path() + .app_data_dir() + .ok() + .map(|d| d.join("custom_harnesses")); + managed_agents::custom_harnesses::warm_harness_registry_from_dir( + custom_dir.as_deref(), + ); + } + // Store the AppHandle so huddle commands can emit `huddle-state-changed` // events via `huddle::emit_huddle_state` without threading the handle // through every call site. @@ -532,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()); @@ -623,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}"); } @@ -704,6 +698,8 @@ pub fn run() { discover_acp_providers, discover_git_bash_prerequisite, install_acp_runtime, + save_custom_harness, + delete_custom_harness, connect_acp_runtime, discover_managed_agent_prereqs, sign_event, @@ -809,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, @@ -878,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 13cb6c1b70..ebcc127683 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 ba4407d164..4a7b80079d 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 b0bf8f5991..16a0d35b23 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 4c11cd6c49..4ee4ec79c3 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/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs new file mode 100644 index 0000000000..e6bc09496c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -0,0 +1,1231 @@ +//! Loader for user-defined ACP harness definitions. +//! +//! Users drop JSON files into `/custom_harnesses/` to register +//! arbitrary ACP-speaking agents without modifying the app or opening a PR. +//! Each file describes a single harness; the loader validates, warns on +//! invalid entries, and never propagates errors to the discovery caller. +//! +//! **Security constraint (Will-ratified):** custom definitions carry NO install +//! shell commands. `can_auto_install` is always `false` for custom entries. +//! Only tier-1 compiled-in runtimes retain install-script power. +//! +//! **Avatar URL security:** custom/preset catalog entries MUST NOT carry +//! user-supplied avatar URLs. `HarnessDefinition` intentionally omits +//! `avatar_url` — all icons are bundled assets keyed via `RUNTIME_LOGOS`. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Regex-equivalent predicate for a valid harness ID. +/// +/// IDs must match `[a-z0-9_][a-z0-9_-]*` — lowercase alphanumeric plus +/// hyphens and underscores, starting with an alphanumeric or underscore. +/// This mirrors goose's `generate_id` validation and is intentionally +/// more restrictive than the filesystem to prevent path-traversal tricks. +fn is_valid_harness_id(id: &str) -> bool { + let mut chars = id.chars(); + match chars.next() { + Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +/// Public re-export of `is_valid_harness_id` for callers outside this module +/// (e.g., the `delete_custom_harness` command that must validate caller-supplied ids). +pub(crate) fn is_valid_harness_id_pub(id: &str) -> bool { + is_valid_harness_id(id) +} + +/// User-supplied harness definition deserialized from a JSON file. +/// +/// Only the fields a custom harness definition is permitted to carry are +/// included here — install commands and avatar URLs are intentionally absent +/// (security line: no remote icon URLs from user-editable config). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HarnessDefinition { + /// Unique identifier, must match `[a-z0-9_][a-z0-9_-]*`. + pub id: String, + /// Human-readable name shown in the UI. + pub label: String, + /// Primary executable name or absolute path. May not be empty. + pub command: String, + /// Default CLI arguments passed to the command (array, not split-string). + #[serde(default)] + pub args: Vec, + /// Environment variables injected at spawn time. Definition env is applied + /// first and LOSES on conflict with Buzz-injected vars — `BUZZ_MANAGED_AGENT` + /// is always authoritative and cannot be overridden here. + #[serde(default)] + pub env: BTreeMap, + /// Link to external docs for manual install/setup instructions. + #[serde(default)] + pub install_instructions_url: String, + /// Human-readable install hint shown in Doctor. + #[serde(default)] + pub install_hint: String, +} + +/// Scan `dir` for `*.json` files and deserialize each into a `HarnessDefinition`. +/// +/// Errors per file are logged with `tracing::warn` and skipped — a single +/// malformed file never fails discovery for the rest. Returns only +/// structurally valid, individually validated definitions. +/// +/// Filtering is applied HERE — at the loader boundary — so every consumer +/// (discovery Phase 3, `warm_harness_registry_from_dir`, and through it the +/// spawn/readiness registry) inherits the same guarantees: +/// * `check_id_collision` — a hand-placed `goose.json` can never shadow a +/// built-in or preset id, even on the cold-launch warm path; +/// * duplicate-id dedup within the directory (first file wins). +/// +/// **Callers must supply a fresh `dir` path on every `discover_acp_runtimes` +/// call** — this function performs no caching, mirroring goose's +/// `refresh_custom_providers()` pattern. +pub(crate) fn load_custom_harnesses(dir: &Path) -> Vec { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return vec![], + Err(err) => { + tracing::warn!( + "custom_harnesses: cannot read directory {}: {err}", + dir.display() + ); + return vec![]; + } + }; + + let mut definitions = Vec::new(); + let mut seen_ids = std::collections::HashSet::new(); + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + + let contents = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(err) => { + tracing::warn!("custom_harnesses: failed to read {}: {err}", path.display()); + continue; + } + }; + + let def: HarnessDefinition = match serde_json::from_str(&contents) { + Ok(d) => d, + Err(err) => { + tracing::warn!( + "custom_harnesses: invalid JSON in {}: {err}", + path.display() + ); + continue; + } + }; + + if let Err(reason) = validate_harness_definition(&def) { + tracing::warn!("custom_harnesses: skipping {} — {reason}", path.display()); + continue; + } + + // A custom file must never shadow a built-in or preset id — enforced at + // the loader so the warm path can't admit what discovery would reject. + if let Err(reason) = check_id_collision(&def.id) { + tracing::warn!("custom_harnesses: skipping {} — {reason}", path.display()); + continue; + } + + // Dedup within the directory itself (a file's id is taken from its JSON + // content, not its filename, so two files can carry the same id). + if !seen_ids.insert(def.id.clone()) { + tracing::warn!( + "custom_harnesses: skipping {} — duplicate id {:?}", + path.display(), + def.id + ); + continue; + } + + definitions.push(def); + } + + definitions +} + +/// Validate a deserialized `HarnessDefinition` against the invariants that +/// the rest of the discovery code depends on. +fn validate_harness_definition(def: &HarnessDefinition) -> Result<(), String> { + if def.id.is_empty() { + return Err("id must not be empty".into()); + } + if !is_valid_harness_id(&def.id) { + return Err(format!( + "id {:?} does not match [a-z0-9_][a-z0-9_-]* — use lowercase letters, digits, hyphens, and underscores only", + def.id + )); + } + if def.command.trim().is_empty() { + return Err("command must not be empty".into()); + } + if def.label.trim().is_empty() { + return Err("label must not be empty".into()); + } + // Args travel to the harness through the comma-delimited + // `BUZZ_ACP_AGENT_ARGS` env transport (clap `value_delimiter = ','` on the + // buzz-acp side), so a literal comma inside one argument would silently + // split into two arguments at runtime. Reject at the validation boundary — + // shared by save (UI/Tauri) and load (hand-authored files) — so the + // invariant holds regardless of how the definition arrives. + if let Some(arg) = def.args.iter().find(|a| a.contains(',')) { + return Err(format!( + "args: argument {arg:?} contains a comma — arguments are passed via a \ + comma-delimited transport and would be split at spawn time; \ + use separate argument entries instead" + )); + } + // Validate env keys through the shared boundary validator. This closes the + // reserved-key bypass exploit (BUZZ_AUTH_TAG=x forgery shape), rejects + // NUL bytes that would panic Command::env, and enforces size limits. + crate::managed_agents::env_vars::validate_user_env_keys(&def.env) + .map_err(|e| format!("env: {e}"))?; + // Validate install instructions URL scheme when non-empty. + if !def.install_instructions_url.is_empty() { + let url = def.install_instructions_url.trim(); + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err(format!( + "installInstructionsUrl must start with https:// or http://, got: {:?}", + url + )); + } + } + Ok(()) +} + +/// Public wrapper so the `save_custom_harness` Tauri command can validate +/// without duplicating the rules. +pub(crate) fn validate_harness_definition_pub(def: &HarnessDefinition) -> Result<(), String> { + validate_harness_definition(def) +} + +// ── Built-in ID set ────────────────────────────────────────────────────────── + +/// IDs reserved for the compiled-in catalog. A custom definition whose `id` +/// collides with a built-in or preset is rejected to prevent shadowing (e.g. a +/// file called `cursor.json` hiding the pre-existing tier-2 preset). +/// +/// Derived at compile time from `PRESET_HARNESSES` (tier-2) plus the four +/// tier-1 runtimes — no hand-maintained copy. Adding a preset to +/// `PRESET_HARNESSES` automatically reserves its ID without a separate edit. +fn builtin_ids() -> impl Iterator { + const TIER1: &[&str] = &["goose", "claude", "codex", "buzz-agent"]; + let tier2 = crate::managed_agents::discovery::preset_harness_ids(); + TIER1.iter().copied().chain(tier2.iter().copied()) +} + +/// Return an error string if `id` conflicts with a built-in harness ID. +pub(crate) fn check_id_collision(id: &str) -> Result<(), String> { + if builtin_ids().any(|reserved| reserved.eq_ignore_ascii_case(id)) { + return Err(format!( + "id {:?} is reserved for a built-in harness and cannot be overridden", + id + )); + } + Ok(()) +} + +// ── Loaded harness registry (F2 — spawn resolution for custom/preset) ──────── +// +// `known_acp_runtime` / `known_acp_runtime_exact` only search the static +// `KNOWN_ACP_RUNTIMES` table, so custom and preset harnesses were invisible at +// spawn time, causing silent fallback to buzz-agent. +// +// The fix: `discover_acp_runtimes_from` populates this registry with every +// non-builtin definition after each discovery run. Spawn, readiness, and +// summary paths query `lookup_loaded_harness` to get the live definition for a +// given id or command. If a harness id that an agent references is gone from the +// registry, the caller gets a typed error — never a silent buzz-agent fallback. + +use std::sync::{Arc, RwLock}; + +/// Mutex used by tests to serialize writes to the loaded-harness registry. +/// +/// The registry is a process-global singleton. Parallel test execution can +/// interleave warm → lookup pairs from different tests, causing false failures. +/// Every test that calls `warm_harness_registry_from_dir` or +/// `update_loaded_harness_registry` must hold this guard for the lifetime of +/// its assertion block. +#[cfg(test)] +pub(crate) fn registry_test_lock() -> std::sync::MutexGuard<'static, ()> { + use std::sync::{Mutex, OnceLock}; + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +/// Thread-safe registry of non-builtin (preset + custom) harness definitions, +/// populated on every `discover_acp_runtimes_from` call and queried at spawn time. +fn loaded_harness_registry() -> &'static RwLock>> { + use std::sync::OnceLock; + static REGISTRY: OnceLock>>> = OnceLock::new(); + REGISTRY.get_or_init(|| RwLock::new(Vec::new())) +} + +/// Replace the registry contents with `definitions`. Called once per +/// `discover_acp_runtimes_from` run AND on `save_custom_harness` / +/// `delete_custom_harness` so spawn can always resolve the harness without +/// waiting for the next full discovery. +pub(crate) fn update_loaded_harness_registry(definitions: Vec) { + let arcs: Vec> = definitions.into_iter().map(Arc::new).collect(); + // Use `into_inner` to recover from a poisoned lock — the registry is a + // plain replaceable Vec with no torn invariant, so poison recovery is safe. + let mut guard = match loaded_harness_registry().write() { + Ok(g) => g, + Err(poisoned) => { + tracing::warn!("custom_harnesses: loaded-harness registry was poisoned; recovering"); + poisoned.into_inner() + } + }; + *guard = arcs; +} + +/// Look up a loaded (non-builtin) harness by **id**. Returns `None` when the id +/// is unknown. Uses `into_inner` to recover from a poisoned lock so a panic in +/// one thread never permanently blocks all spawn attempts. +pub(crate) fn lookup_loaded_harness_by_id(id: &str) -> Option> { + let guard = match loaded_harness_registry().read() { + Ok(g) => g, + Err(poisoned) => { + tracing::warn!( + "custom_harnesses: loaded-harness registry read lock was poisoned; recovering" + ); + poisoned.into_inner() + } + }; + guard.iter().find(|d| d.id == id).cloned() +} + +/// Warm the loaded-harness registry synchronously from `custom_dir`. +/// +/// Must be called **before** `restore_managed_agents_on_launch` so that cold +/// relaunches can resolve custom/preset harness ids without a full discover +/// round-trip (which is driven by the frontend and arrives later). +/// +/// This is intentionally lightweight: it only loads the custom JSON files and +/// the static preset list — no PATH probing, no availability checks. +pub(crate) fn warm_harness_registry_from_dir(custom_dir: Option<&std::path::Path>) { + // Load only the preset list from the discovery module (static, free). + let preset_defs = crate::managed_agents::discovery::preset_harness_definitions(); + let custom_defs = custom_dir.map(load_custom_harnesses).unwrap_or_default(); + let mut all: Vec = preset_defs; + all.extend(custom_defs); + update_loaded_harness_registry(all); +} + +/// Publish the loaded-harness registry from a **fresh** directory read while +/// holding the persist mutex — the discovery-side counterpart of +/// `save_and_warm` / `delete_and_warm`. +/// +/// `discover_acp_runtimes_from` runs slow auth probes between its directory +/// scan and its registry publish. Publishing the pre-probe snapshot could +/// clobber a `save_and_warm` that landed in that window, leaving a freshly +/// saved harness unresolvable at spawn until the next discovery. Re-reading +/// the directory at publish time, under the same mutex as save/delete, makes +/// the documented guarantee ("the registry always reflects disk at warm time") +/// actually hold. Only the publish is locked — never the probes. +pub(crate) fn warm_harness_registry_locked(custom_dir: Option<&std::path::Path>) { + let _guard = persist_mutex().lock().unwrap_or_else(|e| e.into_inner()); + warm_harness_registry_from_dir(custom_dir); +} + +/// Global mutex that serializes save/delete filesystem mutations and the +/// subsequent registry warm as a single atomic unit. +/// +/// Without this lock, two concurrent `save_custom_harness` calls could +/// interleave: A writes file-A, B writes file-B, B re-warms (sees only B), +/// A re-warms (sees A + B, wins) — but if timing goes the other way B's warm +/// wins and misses A. Holding the lock for the write+warm pair guarantees that +/// the registry always reflects the complete set of files on disk at the time +/// of the warm. +fn persist_mutex() -> &'static std::sync::Mutex<()> { + use std::sync::{Mutex, OnceLock}; + static PERSIST: OnceLock> = OnceLock::new(); + PERSIST.get_or_init(|| Mutex::new(())) +} + +/// Write `definition` to `dir`, then atomically warm the loaded-harness +/// registry. Callers MUST use this function (not `save_custom_harness_to_dir` +/// or `warm_harness_registry_from_dir` separately) so that concurrent saves +/// cannot produce a stale registry snapshot. +pub(crate) fn save_and_warm( + dir: &Path, + definition: &HarnessDefinition, + rename_old_id: Option<&str>, +) -> Result { + let _guard = persist_mutex().lock().unwrap_or_else(|e| e.into_inner()); + let outcome = save_custom_harness_to_dir(dir, definition, rename_old_id)?; + warm_harness_registry_from_dir(Some(dir)); + Ok(outcome) +} + +/// Delete `id.json` from `dir`, then atomically warm the loaded-harness +/// registry. Callers MUST use this function (not `fs::remove_file` + +/// `warm_harness_registry_from_dir` separately) for the same reason as +/// `save_and_warm`. +pub(crate) fn delete_and_warm(dir: &Path, id: &str) -> Result<(), String> { + let _guard = persist_mutex().lock().unwrap_or_else(|e| e.into_inner()); + let target = dir.join(format!("{id}.json")); + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("failed to delete harness {id:?}: {e}")), + } + warm_harness_registry_from_dir(Some(dir)); + Ok(()) +} + +/// The outcome of a successful [`save_custom_harness_to_dir`] call. +#[allow(dead_code)] // Fields consumed by tests; production callers use the Result shape. +pub(crate) struct SaveOutcome { + /// The path of the newly written harness file. + pub target_path: std::path::PathBuf, + /// The path of the old file that was removed on a rename, if any. + /// `None` when the id was unchanged or this was a new harness. + pub removed_old_path: Option, +} + +/// Write a harness definition to `dir/.json` using a backup-swap strategy +/// that is safe on all platforms (including Windows where `fs::rename` over an +/// existing file fails with "access denied"): +/// +/// 1. Serialize the definition and write it to a unique temp file via +/// `atomic_write_file`. +/// 2. If the target already exists, rename it to `.bak` (the backup). +/// 3. `commit()` the temp file (renames temp → target). +/// * On success: delete `.bak` (best-effort; a stale `.bak` is harmless). +/// * On failure: restore `.bak` → target so the original is never lost. +/// 4. If `rename_old_id` is `Some`, remove `/.json` after the +/// new file is committed (non-fatal if NotFound). +/// +/// The caller is responsible for full validation (id, env, etc.) BEFORE +/// calling this function — no validation is performed here. +pub(crate) fn save_custom_harness_to_dir( + dir: &Path, + definition: &HarnessDefinition, + rename_old_id: Option<&str>, +) -> Result { + use atomic_write_file::AtomicWriteFile; + use std::io::Write; + + let json = serde_json::to_string_pretty(definition) + .map_err(|e| format!("failed to serialize harness definition: {e}"))?; + + let target_path = dir.join(format!("{}.json", definition.id)); + let bak_path = dir.join(format!("{}.json.bak", definition.id)); + + // Stage write to temp file. + let mut file = AtomicWriteFile::open(&target_path) + .map_err(|e| format!("failed to open {}: {e}", target_path.display()))?; + file.write_all(json.as_bytes()) + .map_err(|e| format!("failed to write harness definition: {e}"))?; + + // Back up the existing target before committing so we can restore on failure. + let had_backup = if target_path.exists() { + std::fs::rename(&target_path, &bak_path).map_err(|e| { + format!( + "failed to back up {} before replace: {e}", + target_path.display() + ) + })?; + true + } else { + false + }; + + // Commit temp → target. If commit fails and we made a backup, restore it. + if let Err(e) = file.commit() { + if had_backup { + if let Err(restore_err) = std::fs::rename(&bak_path, &target_path) { + // Both commit and restore failed — surface both so the user + // can recover manually. + return Err(format!( + "failed to finalize harness definition: {e}; \ + also failed to restore backup: {restore_err} \ + (original may be at {})", + bak_path.display() + )); + } + } + return Err(format!("failed to finalize harness definition: {e}")); + } + + // Commit succeeded — remove the backup (best-effort; stale .bak is harmless). + if had_backup { + let _ = std::fs::remove_file(&bak_path); + } + + // Remove old file on id rename, after the new file is committed. + let mut removed_old_path = None; + if let Some(old_id) = rename_old_id { + let old_path = dir.join(format!("{old_id}.json")); + match std::fs::remove_file(&old_path) { + Ok(()) => removed_old_path = Some(old_path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + // New file is already committed — log and continue so the + // registry re-warm picks up the new id. + tracing::warn!( + "save_custom_harness_to_dir: failed to remove old harness {old_id:?}: {e}" + ); + } + } + } + + Ok(SaveOutcome { + target_path, + removed_old_path, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + // ── ID validation ──────────────────────────────────────────────────────── + + #[test] + fn valid_id_lowercase_with_hyphen() { + assert!(is_valid_harness_id("my-agent")); + } + + #[test] + fn valid_id_underscore_start() { + assert!(is_valid_harness_id("_my_agent")); + } + + #[test] + fn valid_id_alphanumeric() { + assert!(is_valid_harness_id("agent42")); + } + + #[test] + fn invalid_id_uppercase() { + assert!(!is_valid_harness_id("MyAgent")); + } + + #[test] + fn invalid_id_starts_with_hyphen() { + assert!(!is_valid_harness_id("-bad-id")); + } + + #[test] + fn invalid_id_empty() { + assert!(!is_valid_harness_id("")); + } + + #[test] + fn invalid_id_path_traversal() { + assert!(!is_valid_harness_id("../etc/passwd")); + } + + // ── Collision check ────────────────────────────────────────────────────── + + #[test] + fn builtin_ids_are_rejected() { + // Tier-1 hard-coded IDs must always be reserved. + for id in &["goose", "claude", "codex", "buzz-agent"] { + assert!(check_id_collision(id).is_err(), "{id} should be rejected"); + } + // Tier-2 preset IDs must also be reserved (derived from PRESET_HARNESSES). + for id in crate::managed_agents::discovery::preset_harness_ids() { + assert!(check_id_collision(id).is_err(), "{id} should be rejected"); + } + } + + #[test] + fn unknown_id_passes_collision_check() { + assert!(check_id_collision("my-custom-agent").is_ok()); + } + + // ── File loading ───────────────────────────────────────────────────────── + + #[test] + fn load_valid_json_returns_definition() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("my-agent.json"), + r#"{"id":"my-agent","label":"My Agent","command":"my-agent-bin"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].id, "my-agent"); + assert_eq!(defs[0].label, "My Agent"); + assert_eq!(defs[0].command, "my-agent-bin"); + } + + #[test] + fn load_skips_non_json_files() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("my-agent.toml"), r#"id = "my-agent""#).unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 0, "non-JSON file should be ignored"); + } + + #[test] + fn load_skips_invalid_json_without_panicking() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("bad.json"), "{ not valid json").unwrap(); + + // Must not panic or propagate an error. + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 0); + } + + #[test] + fn load_skips_definition_with_invalid_id() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("Bad.json"), + r#"{"id":"Bad-Id","label":"Bad","command":"bad"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 0, + "invalid id should cause the entry to be skipped" + ); + } + + #[test] + fn load_skips_definition_with_empty_command() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("empty-cmd.json"), + r#"{"id":"empty-cmd","label":"Empty","command":""}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 0, + "empty command should cause the entry to be skipped" + ); + } + + #[test] + fn load_skips_definition_with_non_http_install_url() { + // installInstructionsUrl must start with https:// or http://. + // A bare path, javascript: URI, or other scheme is rejected. + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("bad-url.json"), + r#"{"id":"bad-url","label":"Bad","command":"bad-bin","installInstructionsUrl":"file:///etc/passwd"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 0, + "non-http install URL should cause the entry to be skipped" + ); + } + + #[test] + fn load_accepts_empty_or_https_install_url() { + let dir = tempfile::tempdir().unwrap(); + // Empty URL is fine (optional field). + fs::write( + dir.path().join("no-url.json"), + r#"{"id":"no-url","label":"No URL","command":"no-url-bin"}"#, + ) + .unwrap(); + // https:// is accepted. + fs::write( + dir.path().join("good-url.json"), + r#"{"id":"good-url","label":"Good URL","command":"good-bin","installInstructionsUrl":"https://example.com/install"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 2, + "empty and https:// URLs must both be accepted" + ); + } + + #[test] + fn load_missing_dir_returns_empty_vec() { + let dir = tempfile::tempdir().unwrap(); + let nonexistent = dir.path().join("does_not_exist"); + + let defs = load_custom_harnesses(&nonexistent); + assert_eq!(defs.len(), 0); + } + + #[test] + fn load_continues_after_one_bad_entry() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("bad.json"), "!!!").unwrap(); + fs::write( + dir.path().join("good.json"), + r#"{"id":"good-one","label":"Good","command":"good-binary"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 1, "bad entry skipped, good entry loaded"); + assert_eq!(defs[0].id, "good-one"); + } + + #[test] + fn load_applies_id_collision_check() { + // A custom file whose id shadows a built-in ("goose") must be dropped + // BY THE LOADER — `load_custom_harnesses` is the enforcement boundary + // shared by both the warm path and discovery. This exercises the real + // loader against a real file, not just the helper predicate. + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("goose.json"), + r#"{"id":"goose","label":"Not Goose","command":"goose","args":["--evil"]}"#, + ) + .unwrap(); + assert!( + load_custom_harnesses(dir.path()).is_empty(), + "loader must drop a file shadowing a builtin id" + ); + assert!(check_id_collision("goose").is_err()); + assert!(check_id_collision("custom-goose").is_ok()); + } + + #[test] + fn load_dedups_duplicate_ids_first_file_wins() { + // Two files carrying the same custom id: the loader must keep exactly + // one definition (directory-order first wins; the duplicate is dropped). + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("a.json"), + r#"{"id":"custom-dup","label":"First","command":"first-cmd"}"#, + ) + .unwrap(); + fs::write( + dir.path().join("b.json"), + r#"{"id":"custom-dup","label":"Second","command":"second-cmd"}"#, + ) + .unwrap(); + let loaded = load_custom_harnesses(dir.path()); + assert_eq!( + loaded.len(), + 1, + "loader must dedup duplicate ids within the directory" + ); + assert_eq!(loaded[0].id, "custom-dup"); + } + + // ── Round-trip via save_custom_harness_to_dir (B-4) ───────────────────── + // + // These tests exercise the REAL persistence helper, not raw fs::write. + // They prove: create, same-ID edit (backup-swap), rename (old file removed), + // backup file cleaned up on success. + + fn make_def(id: &str, label: &str) -> HarnessDefinition { + HarnessDefinition { + id: id.to_string(), + label: label.to_string(), + command: format!("{id}-bin"), + args: vec![], + env: BTreeMap::new(), + install_instructions_url: String::new(), + install_hint: String::new(), + } + } + + #[test] + fn save_to_dir_create_writes_file_and_loads_back() { + let dir = tempfile::tempdir().unwrap(); + let def = make_def("my-harness", "My Harness"); + + let outcome = save_custom_harness_to_dir(dir.path(), &def, None).unwrap(); + + assert_eq!(outcome.target_path, dir.path().join("my-harness.json")); + assert!(outcome.removed_old_path.is_none(), "no old file on create"); + + let loaded = load_custom_harnesses(dir.path()); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "my-harness"); + assert_eq!(loaded[0].label, "My Harness"); + } + + #[test] + fn save_to_dir_same_id_edit_replaces_content() { + let dir = tempfile::tempdir().unwrap(); + let v1 = make_def("my-harness", "V1 Label"); + save_custom_harness_to_dir(dir.path(), &v1, None).unwrap(); + + // Same-ID edit: label changes. + let v2 = make_def("my-harness", "V2 Label"); + let outcome = save_custom_harness_to_dir(dir.path(), &v2, None).unwrap(); + + // No old-path reported (id unchanged). + assert!(outcome.removed_old_path.is_none()); + + let loaded = load_custom_harnesses(dir.path()); + assert_eq!(loaded.len(), 1, "same-id edit must not duplicate entries"); + assert_eq!(loaded[0].label, "V2 Label", "v2 content must be present"); + } + + #[test] + fn save_to_dir_backup_is_cleaned_up_after_same_id_edit() { + let dir = tempfile::tempdir().unwrap(); + let v1 = make_def("my-harness", "V1"); + save_custom_harness_to_dir(dir.path(), &v1, None).unwrap(); + + let v2 = make_def("my-harness", "V2"); + save_custom_harness_to_dir(dir.path(), &v2, None).unwrap(); + + // .bak file must be gone after a successful commit. + let bak = dir.path().join("my-harness.json.bak"); + assert!( + !bak.exists(), + ".bak file must be removed after successful same-id edit" + ); + } + + #[test] + fn save_to_dir_rename_removes_old_file_and_creates_new() { + let dir = tempfile::tempdir().unwrap(); + let old_def = make_def("old-id", "Old"); + save_custom_harness_to_dir(dir.path(), &old_def, None).unwrap(); + + // Rename: new id, old_id supplied. + let new_def = make_def("new-id", "New"); + let outcome = save_custom_harness_to_dir(dir.path(), &new_def, Some("old-id")).unwrap(); + + // The outcome carries the old path that was removed. + let expected_old = dir.path().join("old-id.json"); + assert_eq!( + outcome.removed_old_path, + Some(expected_old.clone()), + "removed_old_path must be the old file" + ); + + // Old file gone, new file present. + assert!(!expected_old.exists(), "old-id.json must be removed"); + let loaded = load_custom_harnesses(dir.path()); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "new-id"); + } + + #[test] + fn save_to_dir_rename_nonexistent_old_id_is_non_fatal() { + // rename_old_id pointing to a file that does not exist must succeed + // (NotFound is silently ignored by the helper). + let dir = tempfile::tempdir().unwrap(); + let def = make_def("alpha", "Alpha"); + let outcome = save_custom_harness_to_dir(dir.path(), &def, Some("ghost-id")).unwrap(); + + // New file created, no old path removed. + assert_eq!(outcome.target_path, dir.path().join("alpha.json")); + assert!( + outcome.removed_old_path.is_none(), + "NotFound old-id must not be reported as removed" + ); + assert!(load_custom_harnesses(dir.path()).len() == 1); + } + + #[test] + fn save_to_dir_roundtrip_with_env_preserves_values() { + let dir = tempfile::tempdir().unwrap(); + let mut env = BTreeMap::new(); + env.insert("MY_KEY".to_string(), "my_value".to_string()); + let def = HarnessDefinition { + id: "env-harness".to_string(), + label: "Env Harness".to_string(), + command: "env-bin".to_string(), + args: vec!["--flag".to_string()], + env, + install_instructions_url: "https://example.com".to_string(), + install_hint: "Install from example.com".to_string(), + }; + + save_custom_harness_to_dir(dir.path(), &def, None).unwrap(); + + let loaded = load_custom_harnesses(dir.path()); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].args, vec!["--flag"]); + assert_eq!( + loaded[0].env.get("MY_KEY").map(String::as_str), + Some("my_value"), + "env must round-trip through save_custom_harness_to_dir" + ); + } + + // ── B-3: env validation boundary (validate_harness_definition_pub integration) ── + + #[test] + fn validate_rejects_malformed_key_with_equals_sign() { + // BUZZ_AUTH_TAG=x is the documented reserved-key bypass shape: + // the key contains '=' so Command::env would produce + // `BUZZ_AUTH_TAG=x=forged` in the child env. + let mut env = BTreeMap::new(); + env.insert("BUZZ_AUTH_TAG=x".to_string(), "forged".to_string()); + let def = HarnessDefinition { + id: "bad-env".to_string(), + label: "Bad".to_string(), + command: "bad-bin".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("env var keys must match"), + "malformed key must be rejected: {err}" + ); + assert!( + err.contains("BUZZ_AUTH_TAG"), + "error must name the offending key: {err}" + ); + } + + #[test] + fn validate_rejects_reserved_key_buzz_managed_agent() { + // BUZZ_MANAGED_AGENT and BUZZ_MANAGED_AGENT_START_NONCE are the + // ownership markers — supplying them in a definition must be rejected. + let mut env = BTreeMap::new(); + env.insert( + "BUZZ_MANAGED_AGENT".to_string(), + "fake-instance".to_string(), + ); + let def = HarnessDefinition { + id: "bad-marker".to_string(), + label: "Bad".to_string(), + command: "bad-bin".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("reserved by Buzz"), + "ownership marker key must be rejected: {err}" + ); + } + + #[test] + fn validate_rejects_reserved_key_case_insensitive() { + // BUZZ_PRIVATE_KEY in any casing must be blocked. + let mut env = BTreeMap::new(); + env.insert("buzz_private_key".to_string(), "secret".to_string()); + let def = HarnessDefinition { + id: "ci-marker".to_string(), + label: "CI".to_string(), + command: "ci-bin".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("reserved by Buzz"), + "reserved key must be blocked case-insensitively: {err}" + ); + } + + #[test] + fn validate_rejects_nul_byte_in_value() { + // A NUL in a value would cause Command::env to panic at spawn time. + let mut env = BTreeMap::new(); + env.insert("MY_KEY".to_string(), "val\x00ue".to_string()); + let def = HarnessDefinition { + id: "nul-val".to_string(), + label: "NUL".to_string(), + command: "nul-bin".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("NUL bytes"), + "NUL value must be rejected at validation: {err}" + ); + } + + #[test] + fn validate_rejects_value_over_per_value_size_limit() { + use crate::managed_agents::env_vars::MAX_ENV_VALUE_BYTES; + let mut env = BTreeMap::new(); + // One byte over the per-value cap. + env.insert("BIG_VAL".to_string(), "x".repeat(MAX_ENV_VALUE_BYTES + 1)); + let def = HarnessDefinition { + id: "big-val".to_string(), + label: "Big".to_string(), + command: "big-bin".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("per-value limit"), + "oversized value must be rejected: {err}" + ); + } + + #[test] + fn validate_accepts_well_formed_env() { + let mut env = BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "sk-test-123".to_string()); + env.insert("MODEL_VERSION".to_string(), "claude-3".to_string()); + let def = HarnessDefinition { + id: "good-env".to_string(), + label: "Good".to_string(), + command: "good-bin".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + assert!( + validate_harness_definition_pub(&def).is_ok(), + "well-formed definition must pass validation" + ); + } + + // ── Comma-in-args validation (transport-lossiness guard) ───────────────── + + /// A definition whose args contain a literal comma must be rejected at the + /// validation boundary — the comma-delimited `BUZZ_ACP_AGENT_ARGS` + /// transport would silently split it into two args at spawn time. + #[test] + fn validate_rejects_comma_in_args() { + let mut def = make_def("comma-args", "Comma"); + def.args = vec!["--name".to_string(), "a,b".to_string()]; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("comma"), + "error must explain the comma transport limit, got: {err}" + ); + } + + /// Comma-free args pass — including args with spaces and special chars. + #[test] + fn validate_accepts_comma_free_args() { + let mut def = make_def("clean-args", "Clean"); + def.args = vec!["acp".to_string(), "--flag=x y".to_string()]; + assert!(validate_harness_definition_pub(&def).is_ok()); + } + + /// The loader shares the same validator: a hand-authored file with a comma + /// in args is skipped, so the invariant holds regardless of how the + /// definition arrives (UI save or hand-edited JSON). + #[test] + fn load_skips_definition_with_comma_in_args() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("comma.json"), + r#"{"id":"comma-file","label":"Comma","command":"comma-bin","args":["a,b"]}"#, + ) + .unwrap(); + assert!( + load_custom_harnesses(dir.path()).is_empty(), + "comma-in-args definition must be skipped at the loader boundary" + ); + } + + // ── Discovery publish under persist_mutex (stale-snapshot regression) ──── + + /// Discovery's registry publish must re-read the directory at publish time + /// (under the persist mutex), not push a snapshot taken before the auth + /// probes ran. Regression shape: discovery scans dir → user saves harness X + /// (save_and_warm warms the registry with X) → discovery finishes. If + /// discovery published its pre-save snapshot, X would be on disk but + /// unresolvable at spawn until the next discover. + /// + /// Deterministic interleaving: we simulate it by calling the publish seam + /// (`warm_harness_registry_locked`) after a save that happened "during" + /// discovery — the fresh-read semantics mean the just-saved definition + /// survives the publish. + #[test] + fn discovery_publish_after_concurrent_save_keeps_saved_harness() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // Discovery "scans" the dir while it is empty (stale snapshot would be []). + let stale_snapshot = load_custom_harnesses(dir.path()); + assert!(stale_snapshot.is_empty()); + + // A save lands mid-discovery (save_and_warm: write + warm). + let def = make_def("mid-save", "Mid Save"); + save_and_warm(dir.path(), &def, None).unwrap(); + assert!(lookup_loaded_harness_by_id("mid-save").is_some()); + + // Discovery publishes — the locked warm re-reads the directory, so the + // just-saved harness must survive (a stale-snapshot publish would + // clobber it). + warm_harness_registry_locked(Some(dir.path())); + assert!( + lookup_loaded_harness_by_id("mid-save").is_some(), + "publish must re-read the directory, not clobber the mid-discovery save" + ); + } + + /// Same shape for delete: a delete landing mid-discovery must not be + /// resurrected by the discovery publish. + #[test] + fn discovery_publish_after_concurrent_delete_keeps_harness_gone() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + let def = make_def("mid-delete", "Mid Delete"); + save_and_warm(dir.path(), &def, None).unwrap(); + + // Discovery "scans" while the file exists (stale snapshot would contain it). + let stale_snapshot = load_custom_harnesses(dir.path()); + assert_eq!(stale_snapshot.len(), 1); + + // Delete lands mid-discovery. + delete_and_warm(dir.path(), "mid-delete").unwrap(); + assert!(lookup_loaded_harness_by_id("mid-delete").is_none()); + + // Discovery publishes — fresh read keeps it gone. + warm_harness_registry_locked(Some(dir.path())); + assert!( + lookup_loaded_harness_by_id("mid-delete").is_none(), + "publish must not resurrect a harness deleted mid-discovery" + ); + } + + // ── Registry warm path ─────────────────────────────────────────────────── + + /// After `warm_harness_registry_from_dir` the registry contains preset + + /// custom definitions and `lookup_loaded_harness_by_id` resolves them. + #[test] + fn warm_registry_then_lookup_finds_custom_and_preset_entries() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("my-custom.json"), + r#"{"id":"my-custom","label":"My Custom","command":"my-custom-bin"}"#, + ) + .unwrap(); + + warm_harness_registry_from_dir(Some(dir.path())); + + // Custom entry must be findable. + let found = lookup_loaded_harness_by_id("my-custom"); + assert!( + found.is_some(), + "warm registry must contain the custom entry" + ); + assert_eq!(found.unwrap().command, "my-custom-bin"); + + // At least one preset entry must be in the registry (e.g. "cursor"). + let preset = lookup_loaded_harness_by_id("cursor"); + assert!( + preset.is_some(), + "warm registry must contain preset entries" + ); + } + + /// `warm_harness_registry_from_dir` with `None` still loads presets. + #[test] + fn warm_registry_with_no_custom_dir_loads_presets_only() { + let _lock = registry_test_lock(); + warm_harness_registry_from_dir(None); + // At least the "cursor" preset must be present. + assert!( + lookup_loaded_harness_by_id("cursor").is_some(), + "presets must be reachable even without a custom dir" + ); + } + + /// `warm_harness_registry_from_dir` followed by `update_loaded_harness_registry` + /// with an empty slice clears the registry (transactional save/delete contract). + #[test] + fn warm_then_clear_registry_empties_lookup() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("tmp-agent.json"), + r#"{"id":"tmp-agent","label":"Tmp","command":"tmp-bin"}"#, + ) + .unwrap(); + + warm_harness_registry_from_dir(Some(dir.path())); + assert!(lookup_loaded_harness_by_id("tmp-agent").is_some()); + + // Simulate delete — re-warm with empty dir. + let empty_dir = tempfile::tempdir().unwrap(); + warm_harness_registry_from_dir(Some(empty_dir.path())); + assert!( + lookup_loaded_harness_by_id("tmp-agent").is_none(), + "deleted harness must not appear after re-warm" + ); + } + + // ── Legacy avatarUrl regression (F1) ───────────────────────────────────── + + /// A JSON file that contains a legacy `avatarUrl` field (from pre-BYOH code) + /// must still deserialize without error (unknown-field handling) and the + /// loaded `HarnessDefinition` must NOT carry the URL — the field is absent + /// from the struct so serde drops it. + #[test] + fn legacy_avatar_url_in_json_is_silently_dropped_on_load() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("legacy.json"), + r#"{ + "id": "legacy-agent", + "label": "Legacy Agent", + "command": "legacy-bin", + "avatarUrl": "https://tracking.example.com/logo.png" + }"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + // The file must deserialize successfully (serde ignores unknown fields). + assert_eq!(defs.len(), 1, "legacy file with avatarUrl must still load"); + assert_eq!(defs[0].id, "legacy-agent"); + // HarnessDefinition has no avatar_url field — prove the URL cannot + // be routed to a catalog entry by serializing back and checking. + let json = serde_json::to_string(&defs[0]).unwrap(); + assert!( + !json.contains("https://tracking.example.com"), + "serialized HarnessDefinition must not contain the legacy avatar URL" + ); + } + + // ── Preset id reservation ──────────────────────────────────────────────── + + /// All preset ids must be blocked by `check_id_collision`. + #[test] + fn preset_ids_are_reserved_and_cannot_be_used_as_custom_ids() { + // Derived from PRESET_HARNESSES — no hard-coded copy here so this test + // automatically covers any future preset additions. + for id in crate::managed_agents::discovery::preset_harness_ids() { + assert!( + check_id_collision(id).is_err(), + "preset id {id:?} should be rejected by check_id_collision" + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index a2e47d1281..eecbf4de3e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use crate::managed_agents::{ buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir, AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, + HarnessSource, }; mod runtime_metadata; @@ -57,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 }) @@ -79,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, @@ -111,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, @@ -143,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, @@ -286,6 +293,7 @@ pub fn default_agent_command() -> String { /// 1. explicit override (non-empty) — a deliberate per-instance pin; /// 2. the record's own `runtime` id mapped to its primary command — /// records materialize their runtime at create/migration time; +/// checks both static builtins AND the loaded preset/custom registry; /// 3. legacy fallback: the linked persona's `runtime` (records created /// before the unified model carry `persona_id` but no `runtime`); /// 4. `default_agent_command()`. @@ -302,13 +310,17 @@ pub fn record_agent_command( return pin.to_string(); } - if let Some(command) = record - .runtime - .as_deref() - .and_then(known_acp_runtime_exact) - .and_then(|r| r.commands.first().copied()) - { - return command.to_string(); + if let Some(id) = record.runtime.as_deref() { + // Check static builtins first. + if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) + { + return command.to_string(); + } + // Fall back to loaded registry for preset/custom harnesses. + if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + { + return def.command.clone(); + } } effective_agent_command(record.persona_id.as_deref(), personas, None) @@ -320,7 +332,8 @@ pub fn record_agent_command( /// /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; -/// 2. the linked persona's `runtime` id mapped to its primary command; +/// 2. the linked persona's `runtime` id mapped to its primary command +/// (checks builtins then loaded preset/custom registry); /// 3. `default_agent_command()` — no persona/runtime, or persona deleted. pub fn effective_agent_command( persona_id: Option<&str>, @@ -334,18 +347,119 @@ pub fn effective_agent_command( return pin.to_string(); } - persona_id + let runtime_id = persona_id .and_then(|pid| personas.iter().find(|p| p.id == pid)) - .and_then(|persona| persona.runtime.as_deref()) - .and_then(known_acp_runtime_exact) - .and_then(|r| r.commands.first().copied()) - .map(str::to_string) - .unwrap_or_else(default_agent_command) + .and_then(|persona| persona.runtime.as_deref()); + + if let Some(id) = runtime_id { + // Check static builtins first. + if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) + { + return command.to_string(); + } + // Check loaded preset/custom registry. + if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + { + return def.command.clone(); + } + } + + default_agent_command() } mod overrides; pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; +/// Prefix of the typed dangling-harness error produced by +/// `try_record_agent_command` / `resolve_effective_harness_descriptor`. +/// +/// This sentinel is an internal Rust contract: user-facing surfaces must +/// convert it to a sentence via [`user_facing_harness_error`] (spawn) or to +/// the missing id via [`dangling_harness_id`] (summary) — never show it raw. +pub(crate) const DANGLING_HARNESS_PREFIX: &str = "DANGLING_HARNESS_ID:"; + +/// Extract the missing harness id from a `DANGLING_HARNESS_ID:` error. +/// Returns `None` for any other error string. +pub(crate) fn dangling_harness_id(error: &str) -> Option<&str> { + error.strip_prefix(DANGLING_HARNESS_PREFIX) +} + +/// Convert a harness-resolution error to a user-facing sentence. Dangling +/// harness ids become an actionable message; other errors pass through. +pub(crate) fn user_facing_harness_error(error: &str) -> String { + match dangling_harness_id(error) { + Some(id) => format!( + "harness \"{id}\" was deleted — pick a new harness for this agent or restore the harness definition" + ), + None => error.to_string(), + } +} + +/// Summary-row display for a dangling harness id: shows the *missing* id so +/// the agent list tells the same story as spawn (which refuses with the +/// sentence above), rather than silently falling back to the default command +/// as if the agent were healthy. +pub(crate) fn dangling_harness_display(id: &str) -> String { + format!("harness (deleted): {id}") +} + +/// Spawn-time variant of `record_agent_command` that returns a typed error when +/// a record's `runtime` id or its persona's `runtime` id is set but cannot be +/// resolved (i.e. the definition was deleted after the agent was created). +/// +/// Returns `Err("DANGLING_HARNESS_ID:")` so callers can surface the error +/// without falling through to `buzz-agent`. When there is no runtime id at all +/// the fallback to `default_agent_command()` is intentional (legacy agents +/// pre-date the unified harness model). +pub fn try_record_agent_command( + record: &crate::managed_agents::types::ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], +) -> Result { + // Explicit pin always wins — if the user set a raw override, honour it. + if let Some(pin) = record + .agent_command_override + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + return Ok(pin.to_string()); + } + + // Record-level runtime id: if set but unresolvable → typed error. + if let Some(id) = record.runtime.as_deref() { + if let Some(cmd) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) { + return Ok(cmd.to_string()); + } + if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + { + return Ok(def.command.clone()); + } + return Err(format!("DANGLING_HARNESS_ID:{id}")); + } + + // Persona-level runtime id. + if let Some(persona_id) = record.persona_id.as_deref() { + if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { + if let Some(id) = persona.runtime.as_deref() { + if let Some(cmd) = + known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) + { + return Ok(cmd.to_string()); + } + if let Some(def) = + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + { + return Ok(def.command.clone()); + } + return Err(format!("DANGLING_HARNESS_ID:{id}")); + } + } + } + + // No runtime id set — legacy agent; use the safe default. + Ok(default_agent_command()) +} + fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), @@ -489,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` @@ -1049,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 @@ -1066,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); @@ -1131,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( @@ -1163,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 ) } @@ -1194,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") @@ -1286,6 +1419,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr // Filled in by the auth-probe phase in full catalog discovery. auth_status: AuthStatus::Unknown, login_hint: None, + source: HarnessSource::Builtin, + // Builtin entries have no user-editable env; definition_env is empty. + definition_env: Default::default(), }, } } @@ -1300,8 +1436,246 @@ pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option Vec { - // Phase 1: build all entries (fast — no probes yet). +// ── Tier-2 preset harnesses ──────────────────────────────────────────────── +// +// Static data for well-known ACP harnesses that have bundled logos and +// verified command/args. PATH-probed at discovery time (Detected badge); +// not editable or deletable by users. Logos are bundled assets referenced +// by id in the frontend `RUNTIME_LOGOS` map. + +struct PresetHarness { + id: &'static str, + label: &'static str, + command: &'static str, + args: &'static [&'static str], + install_instructions_url: &'static str, + install_hint: &'static str, + /// Vendor CLI the ACP command wraps, when the preset is an adapter + /// (e.g. Amp's `amp-acp` wraps the separately-installed `amp` CLI). + /// Consulted only when the adapter is absent, so `AdapterMissing` + /// replaces the misleading `NotInstalled` when the CLI is present but + /// the adapter is not. Deliberately NOT fed through the builtins' + /// full `classify_runtime` predicate: that would flip + /// adapter-present/CLI-absent from today's `Available` to `CliMissing` + /// (unselectable), and presets carry a single flat `install_hint`, so + /// the `CliMissing` copy would tell the user to install the adapter + /// they already have. `None` when the command IS the vendor CLI. + underlying_cli: Option<&'static str>, +} + +/// Build the catalog entry for one preset harness through an injectable +/// resolver — the seam the preset loop consumes and tests bind. +/// +/// Availability consumes only the adapter-missing arm of the builtin +/// predicate: adapter presence alone decides `Available` (exactly today's +/// behavior — an `amp-acp` without `amp` stays selectable), and +/// `underlying_cli` is consulted only when the adapter is absent, to +/// distinguish `AdapterMissing` (vendor CLI present) from `NotInstalled` +/// (neither found). See the `underlying_cli` field doc for why the full +/// `classify_runtime` predicate is deliberately not used here. +fn preset_catalog_entry( + def: &PresetHarness, + resolve: impl Fn(&str) -> Option, +) -> AcpRuntimeCatalogEntry { + let (availability, command, binary_path) = match resolve(def.command) { + Some(path) => ( + AcpAvailabilityStatus::Available, + Some(def.command.to_string()), + Some(path.display().to_string()), + ), + None => { + let underlying_cli_found = def + .underlying_cli + .map(|cli| resolve(cli).is_some()) + .unwrap_or(false); + if underlying_cli_found { + (AcpAvailabilityStatus::AdapterMissing, None, None) + } else { + (AcpAvailabilityStatus::NotInstalled, None, None) + } + } + }; + let underlying_cli_path = def + .underlying_cli + .and_then(resolve) + .map(|p| p.display().to_string()); + + let default_args = normalize_agent_args( + def.command, + def.args.iter().map(|s| s.to_string()).collect(), + ); + + AcpRuntimeCatalogEntry { + id: def.id.to_string(), + label: def.label.to_string(), + // No remote URL — all preset icons are bundled assets. + avatar_url: String::new(), + availability, + command, + binary_path, + default_args, + mcp_command: None, + model_env_var: None, + provider_env_var: None, + thinking_env_var: None, + install_hint: def.install_hint.to_string(), + install_instructions_url: def.install_instructions_url.to_string(), + can_auto_install: false, + // Kept false even for adapter presets: presets carry one flat + // install_hint (the adapter's), so the requiresExternalCli + // "CLI is missing" wording would pair the wrong noun with it. + // The builtin path, with per-availability hints, is the only + // consumer of the true case. + requires_external_cli: false, + underlying_cli_path, + node_required: false, + auth_status: AuthStatus::NotApplicable, + login_hint: None, + source: HarnessSource::Preset, + // Preset entries have static, non-editable env; definition_env is empty. + definition_env: Default::default(), + } +} + +const PRESET_HARNESSES: &[PresetHarness] = &[ + PresetHarness { + id: "cursor", + label: "Cursor", + command: "cursor-agent", + args: &["acp"], + install_instructions_url: "https://cursor.com/downloads", + install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.", + underlying_cli: None, + }, + PresetHarness { + id: "omp", + label: "Oh My Pi", + command: "omp", + args: &["acp"], + install_instructions_url: "https://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 { + id: "grok", + label: "Grok Build", + command: "grok", + args: &["agent", "--always-approve", "stdio"], + install_instructions_url: "https://build.x.ai/docs", + install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.", + underlying_cli: None, + }, + PresetHarness { + id: "opencode", + label: "OpenCode", + command: "opencode", + args: &["acp"], + install_instructions_url: "https://opencode.ai/docs", + install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).", + underlying_cli: None, + }, + PresetHarness { + id: "kimi", + label: "Kimi Code", + command: "kimi", + args: &["acp"], + install_instructions_url: "https://kimi.ai/download", + install_hint: "Buzz talks to Kimi Code through its CLI's ACP mode (kimi acp).", + underlying_cli: None, + }, + PresetHarness { + id: "amp", + label: "Amp", + command: "amp-acp", + args: &[], + install_instructions_url: "https://github.com/tao12345666333/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 { + id: "hermes", + label: "Hermes Agent", + command: "hermes-acp", + args: &[], + install_instructions_url: "https://hermes-agent.nousresearch.com", + install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.", + underlying_cli: None, + }, + PresetHarness { + id: "openclaw", + label: "OpenClaw", + command: "openclaw", + args: &["acp"], + install_instructions_url: "https://docs.openclaw.ai/start/getting-started", + 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` \ + harness process itself, but do NOT automatically reach the \ + Gateway's execution environment. If your tools or agent logic \ + needs BUZZ_* credentials at execution time, set them on the \ + Gateway's own environment separately.", + underlying_cli: None, + }, +]; + +/// Return the static preset harness definitions as `HarnessDefinition` values. +/// +/// Used by `warm_harness_registry_from_dir` to seed the loaded-harness registry +/// at startup before the frontend triggers a full discovery run. +pub(crate) fn preset_harness_definitions( +) -> Vec { + PRESET_HARNESSES + .iter() + .map( + |p| crate::managed_agents::custom_harnesses::HarnessDefinition { + id: p.id.to_string(), + label: p.label.to_string(), + command: p.command.to_string(), + args: p.args.iter().map(|s| s.to_string()).collect(), + env: std::collections::BTreeMap::new(), + install_instructions_url: p.install_instructions_url.to_string(), + install_hint: p.install_hint.to_string(), + }, + ) + .collect() +} + +/// Return the static slice of preset harness IDs. +/// +/// Used by `check_id_collision` in `custom_harnesses` to derive the reserved-ID +/// set from the single source of truth (`PRESET_HARNESSES`) rather than a +/// hand-maintained copy. Adding a preset automatically reserves its ID. +pub(crate) fn preset_harness_ids() -> &'static [&'static str] { + // `PRESET_HARNESSES` is `'static`; we project its `id` fields. + // Computed once via OnceLock to avoid repeated allocations on hot paths. + use std::sync::OnceLock; + static IDS: OnceLock> = OnceLock::new(); + IDS.get_or_init(|| PRESET_HARNESSES.iter().map(|p| p.id).collect()) + .as_slice() +} + +/// Discover all ACP runtimes, optionally merging user-defined custom harnesses +/// from `custom_harnesses_dir`. +/// +/// This is the primary entry point used by the Tauri command layer. It: +/// 1. Builds entries for all compiled-in (`Builtin`) runtimes. +/// 2. Runs auth probes in parallel. +/// 3. Inserts static `Preset` entries (PATH-probed, `source: Preset`). +/// 4. If `custom_harnesses_dir` is `Some`, loads `*.json` files from that +/// directory and appends `Custom` entries — no auth probe, command resolved +/// via PATH, availability is `Available` or `NotInstalled`. +/// +/// The custom dir is re-scanned on every call (goose `refresh_custom_providers` +/// pattern) — no caching, no restart needed to pick up new files. +/// +/// After building the catalog, updates the loaded-harness registry so spawn +/// and readiness paths can resolve preset/custom harness commands without +/// re-running discovery. +pub fn discover_acp_runtimes_from( + custom_harnesses_dir: Option<&Path>, +) -> Vec { + // Phase 1: build all builtin entries (fast — no probes yet). let mut partials: Vec = KNOWN_ACP_RUNTIMES .iter() .map(discover_acp_runtime_phase1) @@ -1356,7 +1730,124 @@ pub fn discover_acp_runtimes() -> Vec { } } - partials.into_iter().map(|p| p.entry).collect() + let mut entries: Vec = partials.into_iter().map(|p| p.entry).collect(); + + // Track all ids seen so far (builtins) to prevent preset/custom collisions. + let mut seen_ids: std::collections::HashSet = + entries.iter().map(|e| e.id.clone()).collect(); + + // Phase 2.5: insert static preset entries (PATH-probed, not editable/deletable). + for def in PRESET_HARNESSES { + if seen_ids.contains(def.id) { + // Builtin or earlier preset shadowed this id — skip silently. + continue; + } + seen_ids.insert(def.id.to_string()); + + entries.push(preset_catalog_entry(def, find_command)); + } + + // Phase 3: load and append custom harness definitions. + if let Some(dir) = custom_harnesses_dir { + // The loader applies collision + duplicate filtering at the boundary, + // so anything it returns is safe to surface in the catalog. Builtin + // shadowing is impossible here (check_id_collision covers builtins and + // presets); `seen_ids` guards only same-run duplicates. + for def in crate::managed_agents::custom_harnesses::load_custom_harnesses(dir) { + if !seen_ids.insert(def.id.clone()) { + tracing::warn!("custom_harnesses: skipping duplicate id {:?}", def.id); + continue; + } + + // Availability: command on PATH → Available, else NotInstalled. + let (availability, command, binary_path) = match find_command(&def.command) { + Some(path) => ( + AcpAvailabilityStatus::Available, + Some(def.command.clone()), + Some(path.display().to_string()), + ), + None => (AcpAvailabilityStatus::NotInstalled, None, None), + }; + + let default_args = normalize_agent_args(&def.command, def.args.clone()); + + entries.push(AcpRuntimeCatalogEntry { + id: def.id.clone(), + label: def.label.clone(), + // F1 security fix: never copy user-supplied avatar URL into the catalog. + // All icons are bundled assets; customs fall back to TerminalSquare in the UI. + avatar_url: String::new(), + availability, + command, + binary_path, + default_args, + // Custom harnesses are plain ACP — no MCP sidecar, no env-var + // model switching, no thinking knobs. + mcp_command: None, + model_env_var: None, + provider_env_var: None, + thinking_env_var: None, + install_hint: def.install_hint.clone(), + install_instructions_url: def.install_instructions_url.clone(), + // Security line: custom definitions carry no install scripts. + can_auto_install: false, + requires_external_cli: false, + underlying_cli_path: None, + node_required: false, + // No auth probe for custom harnesses. + auth_status: AuthStatus::NotApplicable, + login_hint: None, + source: HarnessSource::Custom, + // Carry definition env into the catalog so the edit form can + // read it back — prevents silently erasing env on save. + definition_env: def.env.clone(), + }); + } + } + + // Publish the loaded-harness registry from a FRESH directory read under the + // persist mutex — never from the snapshot taken before the auth probes ran. + // A save/delete landing during Phase 2 already re-warmed the registry; a + // stale-snapshot publish here would clobber it (the just-saved harness + // would become unresolvable at spawn until the next discovery). + // + // This exact line is pinned by `discovery_publish_path_survives_mid_flight_save` + // / `..._drops_mid_flight_delete` (discovery tests), which land a save/delete + // through the pre-publish test hook below and red if this reverts to + // publishing a stale snapshot. + #[cfg(test)] + pre_publish_test_hook::run(); + crate::managed_agents::custom_harnesses::warm_harness_registry_locked(custom_harnesses_dir); + + entries +} + +/// Test-only seam: a callback invoked between discovery's directory scan and +/// its registry publish, so tests can land a `save_and_warm`/`delete_and_warm` +/// in exactly the window the stale-snapshot bug lived in — through the REAL +/// `discover_acp_runtimes_from` call path, not a hand-called seam. +#[cfg(test)] +pub(crate) mod pre_publish_test_hook { + use std::sync::{Mutex, OnceLock}; + + type Hook = Box; + + fn cell() -> &'static Mutex> { + static CELL: OnceLock>> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(None)) + } + + /// Install (or clear, with `None`) the hook. Callers must serialize via + /// `registry_test_lock` — the hook is process-global. + pub(crate) fn set(hook: Option) { + *cell().lock().unwrap_or_else(|e| e.into_inner()) = hook; + } + + pub(crate) fn run() { + if let Some(hook) = cell().lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + hook(); + } + } } pub fn managed_agent_avatar_url(command: &str) -> Option { 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 2fb6a471d4..fdfe9b8be7 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 364caa452b..1b587dca0e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -6,9 +6,9 @@ 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, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + 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, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -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() @@ -189,6 +187,90 @@ fn classifies_cli_missing_when_adapter_found_but_cli_absent() { assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); } +/// Amp-shaped preset: an ACP adapter (`amp-acp`) wrapping a separately +/// installed vendor CLI (`amp`). +const ADAPTER_PRESET: PresetHarness = PresetHarness { + id: "amp-test", + label: "Amp Test", + command: "amp-acp", + args: &[], + install_instructions_url: "https://example.com/install", + install_hint: "Install the amp-acp npm adapter.", + underlying_cli: Some("amp"), +}; + +#[test] +fn preset_entry_adapter_missing_when_underlying_cli_present() { + // Vendor CLI resolves, adapter does not — the state Tyler's Amp + // hand-test hit. Must NOT degrade to the misleading NotInstalled. + let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| { + (cmd == "amp").then(|| PathBuf::from("/usr/local/bin/amp")) + }); + assert_eq!(entry.availability, AcpAvailabilityStatus::AdapterMissing); + assert!(entry.command.is_none()); + assert!(entry.binary_path.is_none()); + assert_eq!( + entry.underlying_cli_path.as_deref(), + Some("/usr/local/bin/amp") + ); + assert!(!entry.requires_external_cli); + assert_eq!(entry.install_hint, "Install the amp-acp npm adapter."); +} + +#[test] +fn preset_entry_not_installed_when_both_missing() { + let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None); + assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); + assert!(entry.underlying_cli_path.is_none()); + assert!(!entry.requires_external_cli); +} + +#[test] +fn preset_entry_available_when_adapter_and_cli_present() { + let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| match cmd { + "amp-acp" => Some(PathBuf::from("/usr/local/bin/amp-acp")), + "amp" => Some(PathBuf::from("/usr/local/bin/amp")), + _ => None, + }); + assert_eq!(entry.availability, AcpAvailabilityStatus::Available); + assert_eq!(entry.command.as_deref(), Some("amp-acp")); + assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp")); + assert_eq!( + entry.underlying_cli_path.as_deref(), + Some("/usr/local/bin/amp") + ); +} + +#[test] +fn preset_entry_stays_available_when_adapter_present_but_cli_absent() { + // Wren's regression guard: today an `amp-acp` install without `amp` + // is Available and selectable. Feeding underlying_cli through the + // FULL classify_runtime predicate would flip this to CliMissing + // (unselectable, with backwards install copy) — the adapter-missing + // arm is the only one presets consume. + let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| { + (cmd == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp")) + }); + assert_eq!(entry.availability, AcpAvailabilityStatus::Available); + assert_eq!(entry.command.as_deref(), Some("amp-acp")); + assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp")); + assert!(entry.underlying_cli_path.is_none()); +} + +#[test] +fn preset_entry_without_underlying_cli_stays_simple() { + // Most presets: the command IS the vendor CLI. No external-CLI flag, + // absent command means plain NotInstalled. + let preset = PresetHarness { + underlying_cli: None, + ..ADAPTER_PRESET + }; + let entry = preset_catalog_entry(&preset, |_| None); + assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); + assert!(!entry.requires_external_cli); + assert!(entry.underlying_cli_path.is_none()); +} + fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { id: id.to_string(), @@ -201,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(), @@ -275,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, @@ -314,6 +400,64 @@ fn record_agent_command_bare_record_defaults() { assert_eq!(record_agent_command(&record, &[]), default_agent_command()); } +// ── try_record_agent_command ───────────────────────────────────────────────── + +/// When the record carries a dangling (unknown) runtime id, `try_record_agent_command` +/// must return `Err` containing "DANGLING_HARNESS_ID" — NEVER the buzz-agent default. +/// This test would fail if the function silently fell back to `default_agent_command()`. +#[test] +fn try_record_agent_command_dangling_runtime_id_returns_err() { + let record = record_with(Some("my-deleted-harness"), None, None); + let result = try_record_agent_command(&record, &[]); + assert!( + result.is_err(), + "dangling runtime id must produce Err, got Ok({:?})", + result.ok() + ); + assert!( + result.unwrap_err().contains("DANGLING_HARNESS_ID"), + "error must name the dangling id" + ); +} + +/// When the persona carries a dangling runtime id, `try_record_agent_command` +/// must also error — the error must not silently resolve to the default. +#[test] +fn try_record_agent_command_dangling_persona_runtime_returns_err() { + let personas = vec![persona_with_runtime("p1", Some("ghost-harness"))]; + let record = record_with(None, Some("p1"), None); + let result = try_record_agent_command(&record, &personas); + assert!( + result.is_err(), + "dangling persona runtime id must produce Err" + ); +} + +/// When neither the record nor persona has any runtime id, `try_record_agent_command` +/// falls back to `default_agent_command()` — this is the legacy-agent path. +#[test] +fn try_record_agent_command_no_runtime_id_defaults_to_buzz_agent() { + let record = record_with(None, None, None); + let result = try_record_agent_command(&record, &[]); + assert_eq!( + result, + Ok(default_agent_command()), + "no runtime id must fall back to the safe default" + ); +} + +/// An explicit agent_command_override always wins, even for a dangling runtime id. +#[test] +fn try_record_agent_command_override_beats_dangling_id() { + let record = record_with(Some("gone-harness"), None, Some("cursor-agent")); + let result = try_record_agent_command(&record, &[]); + assert_eq!( + result, + Ok("cursor-agent".to_string()), + "explicit override must beat a dangling runtime id" + ); +} + #[test] fn effective_agent_command_inherits_persona_runtime() { // No override → persona runtime id maps to its primary command. @@ -607,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 @@ -647,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 ─────────────────── @@ -671,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())); @@ -679,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"); @@ -690,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" ); } @@ -716,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() { @@ -734,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; @@ -752,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. @@ -769,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; @@ -781,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); @@ -805,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" ); } @@ -1269,3 +1464,460 @@ fn test_install_shell_from_some_returns_path() { "install_shell_from(Some) must return the path as Ok" ); } + +// ── Registry lifecycle (C1) ─────────────────────────────────────────────────── +// +// These tests verify the "warm → spawn resolves → delete → spawn errors" lifecycle +// that Paul's C1 ruling requires. They call the production resolution functions +// directly so they would red if warm_harness_registry_from_dir, save/delete +// transactional refresh, or try_record_agent_command were reverted. + +/// After warm_harness_registry_from_dir, a record with a matching custom runtime +/// id resolves to the custom command — NOT the buzz-agent default. +/// +/// This test would fail if warm_harness_registry_from_dir is not called before +/// try_record_agent_command, or if try_record_agent_command ignores the registry. +#[test] +fn registry_warm_then_try_record_resolves_custom_id() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("my-custom-cli.json"), + r#"{"id":"my-custom-cli","label":"My CLI","command":"my-custom-bin"}"#, + ) + .unwrap(); + + warm_harness_registry_from_dir(Some(dir.path())); + + let record = record_with(Some("my-custom-cli"), None, None); + let result = try_record_agent_command(&record, &[]); + assert_eq!( + result, + Ok("my-custom-bin".to_string()), + "warm registry must make custom id resolvable at spawn time" + ); +} + +/// After deleting a custom harness and re-warming the registry, a record that +/// still references the deleted id must produce a DANGLING_HARNESS_ID error — +/// NOT silently fall back to buzz-agent. +/// +/// This test would fail if save/delete commands do not call +/// warm_harness_registry_from_dir transactionally, or if try_record_agent_command +/// silently falls back to default_agent_command() for dangling ids. +#[test] +fn registry_delete_then_try_record_returns_dangling_error() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + let path = dir.path().join("soon-gone.json"); + fs::write( + &path, + r#"{"id":"soon-gone","label":"Gone","command":"soon-gone-bin"}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + // Verify it resolves before delete. + let record = record_with(Some("soon-gone"), None, None); + assert!( + try_record_agent_command(&record, &[]).is_ok(), + "must resolve before delete" + ); + + // Simulate delete + re-warm (as delete_custom_harness does). + fs::remove_file(&path).unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + // Now must produce a typed error. + let result = try_record_agent_command(&record, &[]); + assert!( + result.is_err(), + "deleted id must produce Err after re-warm, got Ok({:?})", + result.ok() + ); + assert!( + result.unwrap_err().contains("DANGLING_HARNESS_ID"), + "error must contain DANGLING_HARNESS_ID" + ); +} + +/// After saving (writing) a harness JSON and re-warming, the record resolves +/// to the new definition — simulating an immediate-save-then-start flow. +#[test] +fn registry_save_immediate_start_resolves_new_command() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + + // Before save: must not resolve. + warm_harness_registry_from_dir(Some(dir.path())); + let record = record_with(Some("fast-harness"), None, None); + assert!( + try_record_agent_command(&record, &[]).is_err(), + "must not resolve before save" + ); + + // Simulate save + transactional re-warm. + fs::write( + dir.path().join("fast-harness.json"), + r#"{"id":"fast-harness","label":"Fast","command":"fast-bin"}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let result = try_record_agent_command(&record, &[]); + assert_eq!( + result, + Ok("fast-bin".to_string()), + "immediate save+start must resolve without a discover_acp_providers round-trip" + ); +} + +/// Editing a custom harness (renaming id + updating command) and re-warming the +/// registry makes both the old id a dangling reference and the new id resolvable. +#[test] +fn registry_edit_with_id_rename_old_dangling_new_resolved() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + // Serialize against all other tests that write to the global registry. + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + + // Create original. + fs::write( + dir.path().join("original-id.json"), + r#"{"id":"original-id","label":"Orig","command":"orig-bin"}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let old_record = record_with(Some("original-id"), None, None); + assert!( + try_record_agent_command(&old_record, &[]).is_ok(), + "original id must resolve" + ); + + // Simulate rename: write new file, remove old file (same as save_custom_harness + // with original_id set), then re-warm. + fs::write( + dir.path().join("renamed-id.json"), + r#"{"id":"renamed-id","label":"Renamed","command":"new-bin"}"#, + ) + .unwrap(); + fs::remove_file(dir.path().join("original-id.json")).unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + // Old id must now be dangling. + let result = try_record_agent_command(&old_record, &[]); + assert!( + result.is_err() && result.unwrap_err().contains("DANGLING_HARNESS_ID"), + "original id must be dangling after rename" + ); + + // New id must resolve. + let new_record = record_with(Some("renamed-id"), None, None); + assert_eq!( + try_record_agent_command(&new_record, &[]), + Ok("new-bin".to_string()), + "new id must resolve after rename" + ); +} + +// ── Dangling-harness sentinel → user-facing surfaces ───────────────────────── + +/// The internal `DANGLING_HARNESS_ID:` sentinel round-trips through +/// `dangling_harness_id` and is never confused with other error strings. +#[test] +fn dangling_harness_id_extracts_id_and_rejects_other_errors() { + use super::{dangling_harness_id, DANGLING_HARNESS_PREFIX}; + assert_eq!( + dangling_harness_id(&format!("{DANGLING_HARNESS_PREFIX}my-harness")), + Some("my-harness") + ); + assert_eq!(dangling_harness_id("some other error"), None); + assert_eq!(dangling_harness_id(""), None); + // Prefix must anchor at the start — a wrapped sentinel is not a sentinel. + assert_eq!( + dangling_harness_id("cannot spawn: DANGLING_HARNESS_ID:x"), + None + ); +} + +/// Spawn surfaces the sentinel as an actionable sentence naming the id; +/// non-sentinel errors pass through untouched. +#[test] +fn user_facing_harness_error_converts_sentinel_to_sentence() { + use super::{user_facing_harness_error, DANGLING_HARNESS_PREFIX}; + let msg = user_facing_harness_error(&format!("{DANGLING_HARNESS_PREFIX}foo")); + assert!( + msg.contains("\"foo\"") && msg.contains("deleted"), + "sentence must name the missing harness, got: {msg}" + ); + assert!( + !msg.contains(DANGLING_HARNESS_PREFIX), + "raw sentinel must never reach the user, got: {msg}" + ); + assert_eq!( + user_facing_harness_error("plain failure"), + "plain failure", + "non-sentinel errors pass through" + ); +} + +/// Composed coherence test (delete → summary display → spawn sentence): after +/// a harness is deleted, the single resolver errors with the sentinel, the +/// summary path renders the *missing id* (not a silent buzz-agent fallback), +/// and the spawn path renders the actionable sentence — both halves tell the +/// same story from the same error. +#[test] +fn deleted_harness_summary_display_and_spawn_sentence_agree() { + use crate::managed_agents::custom_harnesses::{ + delete_and_warm, registry_test_lock, save_and_warm, + }; + use crate::managed_agents::{resolve_effective_harness_descriptor, GlobalAgentConfig}; + + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // Save a custom harness and pin a record to it. + let def = crate::managed_agents::custom_harnesses::HarnessDefinition { + id: "doomed".to_string(), + label: "Doomed".to_string(), + command: "doomed-bin".to_string(), + args: vec![], + env: Default::default(), + install_instructions_url: String::new(), + install_hint: String::new(), + }; + save_and_warm(dir.path(), &def, None).unwrap(); + let record = record_with(Some("doomed"), None, None); + let global = GlobalAgentConfig::default(); + assert!( + resolve_effective_harness_descriptor(&record, &[], &global).is_ok(), + "must resolve before delete" + ); + + // Delete it — the shared resolver used by BOTH spawn and summary errors. + delete_and_warm(dir.path(), "doomed").unwrap(); + let err = resolve_effective_harness_descriptor(&record, &[], &global).unwrap_err(); + + // Summary half: renders the missing id, never the default command. + let id = super::dangling_harness_id(&err).expect("resolver must emit the typed sentinel"); + let display = super::dangling_harness_display(id); + assert_eq!(display, "harness (deleted): doomed"); + assert!(!display.contains(&default_agent_command())); + + // Spawn half: renders a sentence naming the same id, no raw sentinel. + let sentence = super::user_facing_harness_error(&err); + assert!(sentence.contains("\"doomed\"") && sentence.contains("deleted")); + assert!(!sentence.contains(super::DANGLING_HARNESS_PREFIX)); +} + +// ── I2: custom catalog entry carries definition_env for the edit round-trip ─── + +/// A custom harness definition that includes env vars must surface those vars +/// in the `definition_env` field of the resulting `AcpRuntimeCatalogEntry`. +/// +/// This proves the edit-form round-trip: the backend carries env into the +/// catalog, the frontend reads it back when opening the edit form, and Save +/// therefore preserves existing env vars rather than silently erasing them. +#[test] +fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + use std::{collections::BTreeMap, fs}; + use tempfile::tempdir; + + // Discovery's auth probes read/warm the process-global PATH and + // login-shell-PATH caches, and its final step publishes to the global + // harness registry — hold both guards so parallel tests (e.g. the + // PATH-swapping resolution tests) can't observe or absorb torn state. + // Lock order for tests that need both: path lock first, then registry. + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + // Write a custom definition with two env vars. + fs::write( + dir.path().join("env-harness.json"), + r#"{ + "id": "env-harness", + "label": "Env Harness", + "command": "env-harness-bin", + "args": [], + "env": { "CURSOR_ACP": "1", "MY_TOKEN": "abc" } + }"#, + ) + .unwrap(); + + let entries = discover_acp_runtimes_from(Some(dir.path())); + let entry = entries + .iter() + .find(|e| e.id == "env-harness") + .expect("custom entry must appear in catalog"); + + let expected: BTreeMap = [ + ("CURSOR_ACP".to_string(), "1".to_string()), + ("MY_TOKEN".to_string(), "abc".to_string()), + ] + .into_iter() + .collect(); + + assert_eq!( + entry.definition_env, expected, + "catalog entry must carry definition env vars so the edit form can read them back" + ); +} + +/// A builtin catalog entry must have an empty `definition_env` — their env +/// is handled via the `KnownAcpRuntime` metadata path, not user-editable JSON. +#[test] +fn builtin_catalog_entry_has_empty_definition_env() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + + // Same guards as above: discovery probes PATH-dependent caches and + // publishes to the global registry. + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let entries = discover_acp_runtimes_from(None); + // Find any builtin entry (e.g. "goose" or "claude"). + let builtin = entries + .iter() + .find(|e| e.source == crate::managed_agents::HarnessSource::Builtin) + .expect("at least one builtin must exist"); + + assert!( + builtin.definition_env.is_empty(), + "builtin entry must not carry definition_env, got: {:?}", + builtin.definition_env + ); +} + +// ── Discovery publish via the PRODUCTION call path (stale-snapshot regression) ─ +// +// These drive `discover_acp_runtimes_from` itself and land a save/delete in +// the window between its directory scan and its registry publish (via the +// `pre_publish_test_hook` seam). They red if discovery's final line reverts +// to publishing its pre-probe `loaded_defs` snapshot — the original bug — +// unlike the `custom_harnesses` seam tests, which pin only the fresh-read +// contract of `warm_harness_registry_locked`. + +/// RAII guard: installs the pre-publish hook, clears it on drop (even on +/// panic) so a failing test cannot poison later ones. +struct PrePublishHookGuard; + +impl PrePublishHookGuard { + fn install(hook: Box) -> Self { + super::pre_publish_test_hook::set(Some(hook)); + PrePublishHookGuard + } +} + +impl Drop for PrePublishHookGuard { + fn drop(&mut self) { + super::pre_publish_test_hook::set(None); + } +} + +fn harness_def( + id: &str, + label: &str, + command: &str, +) -> crate::managed_agents::custom_harnesses::HarnessDefinition { + crate::managed_agents::custom_harnesses::HarnessDefinition { + id: id.to_string(), + label: label.to_string(), + command: command.to_string(), + args: vec![], + env: Default::default(), + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +/// A `save_and_warm` landing mid-discovery (after the scan, before the +/// publish) must survive discovery's registry publish — through the real +/// `discover_acp_runtimes_from` path. +#[test] +fn discovery_publish_path_survives_mid_flight_save() { + use crate::managed_agents::custom_harnesses::{ + lookup_loaded_harness_by_id, registry_test_lock, save_and_warm, + }; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + + // Path lock first, then registry — discovery's probes touch the global + // PATH caches (see the definition_env tests above for the full rationale). + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // Discovery scans the dir while it is EMPTY; the save lands in the + // pre-publish window. A stale-snapshot publish would clobber it. + let hook_dir = dir.path().to_path_buf(); + let _guard = PrePublishHookGuard::install(Box::new(move || { + let def = harness_def("mid-flight-save", "Mid Flight", "mid-flight-bin"); + save_and_warm(&hook_dir, &def, None).unwrap(); + assert!(lookup_loaded_harness_by_id("mid-flight-save").is_some()); + })); + + let _entries = discover_acp_runtimes_from(Some(dir.path())); + + assert!( + lookup_loaded_harness_by_id("mid-flight-save").is_some(), + "discovery's publish must re-read the directory — a stale-snapshot \ + publish clobbers a save that landed mid-discovery" + ); +} + +/// A `delete_and_warm` landing mid-discovery must stay gone after discovery's +/// publish — a stale snapshot (taken while the file existed) would resurrect it. +#[test] +fn discovery_publish_path_drops_mid_flight_delete() { + use crate::managed_agents::custom_harnesses::{ + delete_and_warm, lookup_loaded_harness_by_id, registry_test_lock, save_and_warm, + }; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + + // Path lock first, then registry (same rationale as the sibling test). + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // File exists at scan time — discovery's snapshot would contain it. + let def = harness_def("mid-flight-delete", "Mid Flight Del", "mid-flight-del-bin"); + save_and_warm(dir.path(), &def, None).unwrap(); + + let hook_dir = dir.path().to_path_buf(); + let _guard = PrePublishHookGuard::install(Box::new(move || { + delete_and_warm(&hook_dir, "mid-flight-delete").unwrap(); + assert!(lookup_loaded_harness_by_id("mid-flight-delete").is_none()); + })); + + let _entries = discover_acp_runtimes_from(Some(dir.path())); + + assert!( + lookup_loaded_harness_by_id("mid-flight-delete").is_none(), + "discovery's publish must not resurrect a harness deleted mid-discovery" + ); +} 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 5886a43990..82bfd27f32 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 2f6b038deb..0795bb2345 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/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs new file mode 100644 index 0000000000..e079c76a1d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use super::global_config::GlobalAgentConfig; +use super::relay_mesh::{ + RELAY_MESH_API_BASE_URL, RELAY_MESH_API_KEY_PLACEHOLDER, RELAY_MESH_AUTO_MODEL_ID, + RELAY_MESH_PROVIDER_ID, +}; +use super::types::{AgentDefinition, ManagedAgentRecord}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfigSource { + Definition, + Global, + InstanceLegacy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedField { + pub value: Option, + pub source: ConfigSource, +} + +#[derive(Debug, Clone)] +pub struct EffectiveAgentConfig { + pub model: ResolvedField, + pub provider: ResolvedField, + pub system_prompt: ResolvedField, +} + +impl EffectiveAgentConfig { + /// The relay-mesh model id this config resolves to, or `None` when the + /// effective provider isn't relay-mesh. + /// + /// This is the single authoritative mesh decision for this config. Both + /// the mesh preflight (interactive start, restore-on-launch) AND spawn's + /// `apply_relay_mesh_env` block MUST derive their mesh gate from this + /// method — never from a separate provider comparison — so the two paths + /// are guaranteed to agree even when the stored provider string has leading + /// or trailing whitespace. The provider is trimmed before matching; + /// a blank effective model falls back to "auto", mirroring + /// `apply_relay_mesh_env`'s own rule. + pub fn relay_mesh_model_id(&self) -> Option { + if self.provider.value.as_deref().map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { + return None; + } + Some( + self.model + .value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_string(), + ) + } +} + +#[derive(Debug, Clone)] +pub enum EffectiveConfigResult { + Resolved(EffectiveAgentConfig), + OrphanedInstance { + record_pubkey: String, + missing_persona_id: String, + }, +} + +fn non_blank(v: Option<&str>) -> Option<&str> { + v.filter(|s| !s.trim().is_empty()) +} + +fn resolve_linked( + definition: &AgentDefinition, + global: &GlobalAgentConfig, +) -> EffectiveAgentConfig { + let model = match non_blank(definition.model.as_deref()) { + Some(m) => ResolvedField { + value: Some(m.to_owned()), + source: ConfigSource::Definition, + }, + None => ResolvedField { + value: global.model.clone(), + source: ConfigSource::Global, + }, + }; + + let provider = match non_blank(definition.provider.as_deref()) { + Some(p) => ResolvedField { + value: Some(p.to_owned()), + source: ConfigSource::Definition, + }, + None => ResolvedField { + value: global.provider.clone(), + source: ConfigSource::Global, + }, + }; + + let system_prompt = ResolvedField { + value: non_blank(Some(definition.system_prompt.as_str())).map(str::to_owned), + source: ConfigSource::Definition, + }; + + EffectiveAgentConfig { + model, + provider, + system_prompt, + } +} + +/// The API key value the relay-mesh preset wrote before the Jun-11 rename +/// window (#960, `8f580f308`) changed `RELAY_MESH_API_KEY_PLACEHOLDER` from +/// `"sprout-mesh-local"` to its current value. The old string is persisted as a +/// *value* in `env_vars` on records created before that, and no migration ever +/// rewrote it. +const LEGACY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local"; + +/// The provider key the preset wrote before #971 (`8c8312932`) renamed it to +/// `BUZZ_AGENT_PROVIDER`. That commit changed source literals only — persisted +/// `env_vars` keys were never migrated. +const LEGACY_MESH_PROVIDER_ENV_KEY: &str = "SPROUT_AGENT_PROVIDER"; + +/// The legacy env discriminator: recognizes the relay-mesh preset purely from +/// the env vars a pre-typed-field record carries, returning its served model id. +/// +/// All three sentinels must match — the local base URL alone is not enough, +/// since a user may point their own OpenAI-compatible provider at the same +/// port. The placeholder API key is what makes this Buzz's own preset. +/// +/// Two of those sentinels were renamed in the same Jun-11 window, in separate +/// commits, with neither migrating persisted records: the provider env *key* +/// (#971) and the API key *value* (#960). Each is therefore accepted under +/// either spelling, independently — a record straddling the window carries one +/// old and one new. In both cases the current spelling is authoritative when +/// present, so a record that has since been rewritten with a non-mesh value is +/// not resurrected by the stale leftover beside it. +/// +/// `OPENAI_COMPAT_BASE_URL` and `OPENAI_COMPAT_MODEL` were never renamed. +/// Nothing beyond these two either/ors is loosened: every sentinel dropped +/// widens the false-positive surface for a user's own openai-compatible agent. +fn mesh_preset_env_model_id(env_vars: &BTreeMap) -> Option { + let base_url = env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim(); + if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL { + return None; + } + let provider = env_vars + .get("BUZZ_AGENT_PROVIDER") + .or_else(|| env_vars.get(LEGACY_MESH_PROVIDER_ENV_KEY))? + .trim(); + if provider != "openai" { + return None; + } + let api_key = env_vars.get("OPENAI_COMPAT_API_KEY")?.trim(); + if api_key != RELAY_MESH_API_KEY_PLACEHOLDER && api_key != LEGACY_MESH_API_KEY_PLACEHOLDER { + return None; + } + non_blank(env_vars.get("OPENAI_COMPAT_MODEL").map(String::as_str)).map(str::to_owned) +} + +/// The mesh model id a definition-less record carries in legacy form, or `None` +/// when it is not a legacy mesh record. +/// +/// Two shipped record generations predate `provider: "relay-mesh"` and are +/// never rewritten on load, so they are still on disk: +/// +/// - the typed `relay_mesh` marker, added before the record had a `provider` +/// field at all; +/// - before that, the mesh preset written straight into `env_vars`. +/// +/// Consulted only by [`resolve_definition_less`]: a record with no definition +/// has nothing authoritative to be overridden by, so its own bytes are the +/// highest-precedence signal it has. A linked instance never reaches here. +fn legacy_record_mesh_model_id(record: &ManagedAgentRecord) -> Option { + match &record.relay_mesh { + // The marker itself is the mesh signal; a blank `model_ref` still means + // mesh, resolved to the auto model exactly as `apply_relay_mesh_env` + // and `relay_mesh_model_id` treat a blank model. + Some(config) => Some( + non_blank(Some(config.model_ref.as_str())) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_owned(), + ), + None => mesh_preset_env_model_id(&record.env_vars), + } +} + +fn resolve_definition_less( + record: &ManagedAgentRecord, + global: &GlobalAgentConfig, +) -> EffectiveAgentConfig { + let model = match non_blank(record.model.as_deref()) { + Some(m) => ResolvedField { + value: Some(m.to_owned()), + source: ConfigSource::InstanceLegacy, + }, + None => ResolvedField { + value: global.model.clone(), + source: ConfigSource::Global, + }, + }; + + let provider = match non_blank(record.provider.as_deref()) { + Some(p) => ResolvedField { + value: Some(p.to_owned()), + source: ConfigSource::InstanceLegacy, + }, + None => ResolvedField { + value: global.provider.clone(), + source: ConfigSource::Global, + }, + }; + + let system_prompt = ResolvedField { + value: non_blank(record.system_prompt.as_deref()).map(str::to_owned), + source: ConfigSource::InstanceLegacy, + }; + + let mut config = EffectiveAgentConfig { + model, + provider, + system_prompt, + }; + + // Legacy mesh compatibility. A record with an explicit `provider` has + // already stated its intent — including switching AWAY from mesh, which + // leaves the old marker and env bytes behind — so its legacy bytes are + // never consulted. Only a record that never carried a provider at all + // falls back, and both fields move together so the single mesh gate + // (`relay_mesh_model_id`) and the spawned model agree. + if non_blank(record.provider.as_deref()).is_none() { + if let Some(model_ref) = legacy_record_mesh_model_id(record) { + config.provider = ResolvedField { + value: Some(RELAY_MESH_PROVIDER_ID.to_owned()), + source: ConfigSource::InstanceLegacy, + }; + config.model = ResolvedField { + value: Some(model_ref), + source: ConfigSource::InstanceLegacy, + }; + } + } + + config +} + +pub fn resolve_effective_config( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> EffectiveConfigResult { + match &record.persona_id { + Some(pid) => match definitions.iter().find(|d| d.id == *pid) { + Some(def) => EffectiveConfigResult::Resolved(resolve_linked(def, global)), + None => EffectiveConfigResult::OrphanedInstance { + record_pubkey: record.pubkey.clone(), + missing_persona_id: pid.clone(), + }, + }, + None => EffectiveConfigResult::Resolved(resolve_definition_less(record, global)), + } +} + +pub fn resolve_effective_model_provider_pair( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Option<(Option, Option)> { + match resolve_effective_config(record, definitions, global) { + EffectiveConfigResult::Resolved(cfg) => Some((cfg.model.value, cfg.provider.value)), + EffectiveConfigResult::OrphanedInstance { .. } => None, + } +} + +/// The relay-mesh preflight decision for `record`, resolved the same way +/// spawn resolves its mesh env: through `resolve_effective_config` (which +/// folds in the definition → global fallback). A linked instance's own +/// `provider`/`model`/`relay_mesh` bytes never contribute; a definition-less +/// legacy record may fall back to them via `legacy_record_mesh_model_id`, +/// which is confined to `resolve_definition_less`. +/// +/// `None` covers both "not a mesh agent" and "orphaned instance" — an orphan +/// never spawns (see `require_resolved`), so it never needs a mesh preflight +/// either; the caller's own orphan handling downstream is unaffected, this +/// just avoids tripping mesh bootstrap for a start that will be refused. +pub fn resolve_effective_relay_mesh_model_id( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Option { + match resolve_effective_config(record, definitions, global) { + EffectiveConfigResult::Resolved(cfg) => cfg.relay_mesh_model_id(), + EffectiveConfigResult::OrphanedInstance { .. } => None, + } +} + +/// The single user-facing message for a linked instance whose definition no +/// longer exists. Shared by every path that must refuse to act on an orphan: +/// the spawn boundary (`spawn_agent_child`), the interactive start command, +/// and provider deploy. +pub const ORPHANED_INSTANCE_ERROR: &str = + "This agent's configuration is missing — it may still be \ + syncing or was deleted on another device."; + +impl EffectiveConfigResult { + /// Unwrap into the resolved config, or the shared orphan-refusal error. + pub fn require_resolved(self) -> Result { + match self { + EffectiveConfigResult::Resolved(cfg) => Ok(cfg), + EffectiveConfigResult::OrphanedInstance { .. } => { + Err(ORPHANED_INSTANCE_ERROR.to_string()) + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs new file mode 100644 index 0000000000..c8e437809c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -0,0 +1,876 @@ +use super::*; +use std::collections::BTreeMap; + +fn definition( + id: &str, + model: Option<&str>, + provider: Option<&str>, + prompt: &str, +) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "Test Definition".to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + runtime: None, + model: model.map(str::to_string), + provider: provider.map(str::to_string), + 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![], + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + } +} + +fn record( + persona_id: Option<&str>, + model: Option<&str>, + provider: Option<&str>, + prompt: Option<&str>, +) -> ManagedAgentRecord { + use crate::managed_agents::{BackendKind, RespondTo}; + ManagedAgentRecord { + pubkey: "agent-pk".to_string(), + name: "Agent".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: prompt.map(str::to_string), + model: model.map(str::to_string), + provider: provider.map(str::to_string), + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: 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: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + 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, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } +} + +fn global(model: Option<&str>, provider: Option<&str>) -> GlobalAgentConfig { + GlobalAgentConfig { + model: model.map(str::to_string), + provider: provider.map(str::to_string), + ..Default::default() + } +} + +// ── Linked instance: definition → global, record ignored ── + +#[test] +fn linked_definition_model_wins_over_stale_record() { + let rec = record( + Some("d1"), + Some("stale-model"), + Some("stale-prov"), + Some("stale prompt"), + ); + let defs = vec![definition( + "d1", + Some("def-model"), + Some("def-prov"), + "def prompt", + )]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("def-model")); + assert_eq!(cfg.model.source, ConfigSource::Definition); + assert_eq!(cfg.provider.value.as_deref(), Some("def-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Definition); + assert_eq!(cfg.system_prompt.value.as_deref(), Some("def prompt")); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_inherit_global_when_definition_blank() { + let rec = record( + Some("d1"), + Some("stale-model"), + Some("stale-prov"), + Some("stale prompt"), + ); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); + assert_eq!(cfg.system_prompt.value, None); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_stale_record_model_is_inert() { + let rec = record(Some("d1"), Some("stale-model"), Some("stale-prov"), None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(None, None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value, None); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value, None); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn linked_definition_model_set_provider_inherits() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("def-model"), None, "prompt")]; + let g = global(None, Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("def-model")); + assert_eq!(cfg.model.source, ConfigSource::Definition); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn linked_blank_prompt_means_no_prompt() { + let rec = record(Some("d1"), None, None, Some("stale prompt on record")); + let defs = vec![definition("d1", None, None, "")]; + let g = global(None, None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.system_prompt.value, None); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_whitespace_only_definition_model_inherits_global() { + let rec = record(Some("d1"), Some("stale"), None, None); + let defs = vec![definition("d1", Some(" "), Some(" \t"), "")]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +// ── Orphaned instance ── + +#[test] +fn orphaned_linked_instance_returns_error() { + let rec = record(Some("missing-def"), None, None, None); + let defs = vec![]; + let g = global(Some("global-model"), None); + + let result = resolve_effective_config(&rec, &defs, &g); + match result { + EffectiveConfigResult::OrphanedInstance { + record_pubkey, + missing_persona_id, + } => { + assert_eq!(record_pubkey, "agent-pk"); + assert_eq!(missing_persona_id, "missing-def"); + } + other => panic!("expected OrphanedInstance, got {:?}", other), + } +} + +// ── Definition-less instance: instance → global ── + +#[test] +fn definition_less_uses_own_fields() { + let rec = record(None, Some("my-model"), Some("my-prov"), Some("my prompt")); + let defs = vec![]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("my-model")); + assert_eq!(cfg.model.source, ConfigSource::InstanceLegacy); + assert_eq!(cfg.provider.value.as_deref(), Some("my-prov")); + assert_eq!(cfg.provider.source, ConfigSource::InstanceLegacy); + assert_eq!(cfg.system_prompt.value.as_deref(), Some("my prompt")); + assert_eq!(cfg.system_prompt.source, ConfigSource::InstanceLegacy); +} + +#[test] +fn definition_less_falls_back_to_global() { + let rec = record(None, None, None, None); + let defs = vec![]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn definition_less_blank_record_fields_fall_through() { + let rec = record(None, Some(" "), Some(""), Some(" ")); + let defs = vec![]; + let g = global(Some("g-model"), None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("g-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value, None); + assert_eq!(cfg.provider.source, ConfigSource::Global); + assert_eq!(cfg.system_prompt.value, None); +} + +// ── Convenience helper ── + +#[test] +fn model_provider_pair_returns_none_for_orphan() { + let rec = record(Some("missing"), None, None, None); + assert_eq!( + resolve_effective_model_provider_pair(&rec, &[], &global(None, None)), + None + ); +} + +#[test] +fn model_provider_pair_returns_resolved_values() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("p"), "")]; + let g = global(None, None); + + let pair = resolve_effective_model_provider_pair(&rec, &defs, &g); + assert_eq!(pair, Some((Some("m".to_string()), Some("p".to_string())))); +} + +// ── require_resolved: the shared orphan-refusal contract used by +// build_deploy_payload and spawn_agent_child ── + +#[test] +fn require_resolved_returns_shared_error_for_orphan() { + let rec = record(Some("missing"), None, None, None); + let error = resolve_effective_config(&rec, &[], &global(None, None)) + .require_resolved() + .expect_err("orphan must not resolve"); + assert_eq!(error, ORPHANED_INSTANCE_ERROR); +} + +#[test] +fn require_resolved_returns_config_for_resolved() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("p"), "prompt")]; + let cfg = resolve_effective_config(&rec, &defs, &global(None, None)) + .require_resolved() + .expect("linked instance with a live definition must resolve"); + assert_eq!(cfg.model.value.as_deref(), Some("m")); +} + +#[test] +fn require_resolved_refuses_orphan_only() { + let orphan = record(Some("missing"), None, None, None); + assert_eq!( + resolve_effective_config(&orphan, &[], &global(None, None)) + .require_resolved() + .unwrap_err(), + ORPHANED_INSTANCE_ERROR, + ); + + let linked = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), None, "")]; + assert!( + resolve_effective_config(&linked, &defs, &global(None, None)) + .require_resolved() + .is_ok() + ); + + // Definition-less instances are never orphaned regardless of how bare + // their own fields are — orphan status only applies to a dangling link. + let bare = record(None, None, None, None); + assert!(resolve_effective_config(&bare, &[], &global(None, None)) + .require_resolved() + .is_ok()); +} + +// ── Morgan's exact regression sequence ── + +#[test] +fn morgans_sequence_inherit_explicit_inherit() { + let g = global(Some("claude-opus-4-6"), Some("anthropic")); + + // Step 1: fresh agent with inherited model → resolves global + let rec_step1 = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "agent prompt")]; + let cfg1 = match resolve_effective_config(&rec_step1, &defs, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 1: {:?}", other), + }; + assert_eq!(cfg1.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!(cfg1.model.source, ConfigSource::Global); + + // Step 2: set explicit model on definition + let defs_explicit = vec![definition( + "d1", + Some("goose-gpt-5-6-sol"), + Some("databricks"), + "agent prompt", + )]; + let cfg2 = match resolve_effective_config(&rec_step1, &defs_explicit, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 2: {:?}", other), + }; + assert_eq!(cfg2.model.value.as_deref(), Some("goose-gpt-5-6-sol")); + assert_eq!(cfg2.model.source, ConfigSource::Definition); + + // Step 3: switch back to inherit — even with stale record bytes + let rec_stale = record( + Some("d1"), + Some("goose-gpt-5-6-sol"), + Some("databricks"), + None, + ); + let defs_inherit = vec![definition("d1", None, None, "agent prompt")]; + let cfg3 = match resolve_effective_config(&rec_stale, &defs_inherit, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 3: {:?}", other), + }; + assert_eq!(cfg3.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!(cfg3.model.source, ConfigSource::Global); + assert_eq!(cfg3.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg3.provider.source, ConfigSource::Global); +} + +// ── relay-mesh preflight resolution (Wes review 2 on #1968) ── +// +// Both regressions below reproduce the exact defects: the preflight must +// key off `resolve_effective_config`'s resolution — the same one spawn's +// mesh env consults — not the record's own `provider`/`model` bytes. + +#[test] +fn relay_mesh_model_id_none_for_non_mesh_config() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("anthropic"), "")]; + let g = global(None, None); + + assert_eq!(resolve_effective_relay_mesh_model_id(&rec, &defs, &g), None); +} + +#[test] +fn relay_mesh_model_id_defaults_to_auto_when_model_blank() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, Some(RELAY_MESH_PROVIDER_ID), "")]; + let g = global(None, None); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// Switch-away regression (Wes finding 1): a linked definition that used to +/// be relay-mesh but was edited to another provider must NOT trigger the mesh +/// preflight — even though the record's own stale bytes still say +/// `provider: relay-mesh`. The old `relay_mesh_config(record)` sniff read +/// those stale record bytes directly and returned Some; the resolver-driven +/// decision must read the definition's CURRENT provider and return None. +#[test] +fn switch_away_from_relay_mesh_clears_preflight_despite_stale_record_bytes() { + let rec = record(Some("d1"), Some("auto"), Some(RELAY_MESH_PROVIDER_ID), None); + let defs = vec![definition( + "d1", + Some("claude-opus-4-6"), + Some("anthropic"), + "", + )]; + let g = global(None, None); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g), + None, + "definition switched away from relay-mesh — no mesh preflight should fire" + ); +} + +/// Global-inheritance regression (Wes finding 2): a blank linked definition +/// falling through to a relay-mesh GLOBAL default must trigger the mesh +/// preflight, even though the record carries `provider: None` and no legacy +/// env — all three of the old `relay_mesh_config` branches would miss here. +#[test] +fn global_relay_mesh_default_triggers_preflight_for_blank_definition() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("Qwen3"), Some(RELAY_MESH_PROVIDER_ID)); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some("Qwen3"), + "global relay-mesh default must trigger the mesh preflight for a blank definition" + ); +} + +/// Symmetric case: a definition-less (legacy) instance inheriting the global +/// relay-mesh default must also trigger the preflight. +#[test] +fn global_relay_mesh_default_triggers_preflight_for_definition_less_instance() { + let rec = record(None, None, None, None); + let defs = vec![]; + let g = global(None, Some(RELAY_MESH_PROVIDER_ID)); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// Orphaned instance: no mesh preflight regardless of stale record bytes — +/// the caller's own orphan refusal is what matters, not a mesh bootstrap for +/// a start that will never happen. +#[test] +fn orphaned_instance_never_triggers_mesh_preflight() { + let rec = record( + Some("missing-def"), + Some("auto"), + Some(RELAY_MESH_PROVIDER_ID), + None, + ); + let g = global(None, None); + + assert_eq!(resolve_effective_relay_mesh_model_id(&rec, &[], &g), None); +} + +// ── Whitespace-provider parity (trim-mismatch regression, T1) ── +// +// The old spawn gate was `effective_provider.as_deref() == Some(RELAY_MESH_PROVIDER_ID)` +// — an exact compare — while `relay_mesh_model_id()` trims before matching. +// A stored provider of " relay-mesh " would make preflight fire while spawn +// skipped the mesh-env block. After the fix spawn derives its gate from +// `relay_mesh_model_id()` too, so the trim semantics are identical. +// +// These tests verify `relay_mesh_model_id()` returns Some for padded providers +// on every resolution path, confirming the helper that both callers now use +// handles whitespace correctly. + +/// Definition provider with leading/trailing whitespace — resolver preserves +/// the raw string, but `relay_mesh_model_id()` trims before matching, so the +/// mesh preflight fires correctly. +#[test] +fn whitespace_provider_in_definition_triggers_mesh_decision() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("qwen3"), Some(" relay-mesh "), "")]; + let g = global(None, None); + + let mesh_id = resolve_effective_relay_mesh_model_id(&rec, &defs, &g); + assert_eq!( + mesh_id.as_deref(), + Some("qwen3"), + "padded definition provider must be treated as relay-mesh by the shared helper" + ); +} + +/// Global provider with leading/trailing whitespace — same assertion for the +/// global-inherit path (blank definition falls through to padded global). +#[test] +fn whitespace_provider_in_global_triggers_mesh_decision() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("qwen3"), Some("\trelay-mesh\t")); + + let mesh_id = resolve_effective_relay_mesh_model_id(&rec, &defs, &g); + assert_eq!( + mesh_id.as_deref(), + Some("qwen3"), + "padded global provider must be treated as relay-mesh by the shared helper" + ); +} + +// ── Legacy relay-mesh record compatibility (Wes review 3 on #1968) ── +// +// Two record generations shipped before `provider: "relay-mesh"` existed and +// are never rewritten on load, so they are still on disk: +// +// Class A (#798) — the four mesh preset env vars in `env_vars`, no typed +// field, no `provider` (the record had no `provider` field yet). +// Class B (#879) — the typed `relay_mesh` marker, still no `provider` field. +// +// Without the fallback both classes resolve to a non-mesh provider while their +// own stale env bytes still reach the child: a silent misroute, not a clean +// failure. The fallback lives in `resolve_definition_less` only — a linked +// instance's definition stays authoritative. + +/// Class A: env-only legacy record resolves mesh via the four-env preset. +#[test] +fn legacy_record_falls_back_to_env_sniff() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1/".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "env-only legacy mesh record must still resolve to its served model" + ); +} + +/// Class A, oldest shipped record: both renamed sentinels in their original +/// spelling. The preset first wrote `SPROUT_AGENT_PROVIDER` (#798) and the +/// api-key value `sprout-mesh-local`; #971 and #960 renamed each in the same +/// Jun-11 window without migrating persisted `env_vars`, so this exact shape is +/// still on disk and must resolve mesh. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_pre_rename_sentinels() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + "sprout-mesh-local".to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "the oldest shipped mesh record carries both pre-rename sentinels and was never migrated" + ); +} + +/// Class A straddling the rename window: old provider key with the new api-key +/// value. Proves the two either/ors are independent rather than one either/or on +/// a bundled old/new pair. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_mixed_rename_sentinels() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "old provider key with new api key must resolve — the sentinels renamed independently" + ); +} + +/// The mirrored straddle: new provider key with the old api-key value. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_new_provider_and_old_api_key() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + "sprout-mesh-local".to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "new provider key with old api key must resolve too" + ); +} + +/// Both spellings present and disagreeing: the renamed key is authoritative, +/// so a record whose current provider env is non-mesh is not resurrected as +/// mesh by the stale `SPROUT_` leftover. +#[test] +fn legacy_env_sniff_prefers_renamed_provider_key_over_stale_old_key() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()), + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)), + None, + "the renamed key states current intent — a stale SPROUT_ key must not override it" + ); +} + +/// Class A negative: a user's own OpenAI-compatible provider on the same local +/// port is not Buzz's preset — the placeholder API key is the discriminator. +#[test] +fn legacy_env_sniff_ignores_user_openai_on_same_local_port() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)), + None, + "a real user key on the mesh port must not be mistaken for Buzz's preset" + ); +} + +/// Class B: typed marker with no `provider` resolves mesh, and its `model_ref` +/// wins over an unrelated global model so preflight and spawn agree. +#[test] +fn legacy_record_falls_back_to_typed_marker_without_provider() { + let mut rec = record(None, None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + + let cfg = match resolve_effective_config(&rec, &[], &global(Some("gpt-5"), Some("openai"))) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => { + panic!("definition-less is never orphaned") + } + }; + + assert_eq!(cfg.provider.value.as_deref(), Some(RELAY_MESH_PROVIDER_ID)); + assert_eq!( + cfg.relay_mesh_model_id().as_deref(), + Some("Qwen3"), + "typed marker must resolve mesh even with no provider on the record" + ); +} + +/// Class B with a blank `model_ref`: the marker is still the mesh signal, and +/// the model resolves to auto — same rule `apply_relay_mesh_env` applies. +#[test] +fn legacy_typed_marker_with_blank_model_resolves_auto() { + let mut rec = record(None, None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: " ".to_string(), + }); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// A definition-less record that was switched AWAY from mesh keeps its stale +/// marker and env bytes on disk. Its explicit `provider` states current intent, +/// so the legacy fallback must not resurrect mesh. +#[test] +fn definition_less_explicit_provider_wins_over_stale_legacy_mesh_bytes() { + let mut rec = record(None, Some("claude-opus-4-6"), Some("anthropic"), None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + let cfg = match resolve_effective_config(&rec, &[], &global(None, None)) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => { + panic!("definition-less is never orphaned") + } + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + cfg.relay_mesh_model_id(), + None, + "a switched-away legacy record must not be dragged back onto mesh" + ); +} + +/// The test that proves the fallback did not reintroduce the bug this PR +/// deletes: a LINKED record carrying both legacy mesh signals resolves purely +/// from its definition and never trips mesh. +#[test] +fn linked_record_ignores_legacy_mesh_marker_and_env() { + let mut rec = record(Some("d1"), Some("auto"), None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + let defs = vec![definition( + "d1", + Some("claude-opus-4-6"), + Some("anthropic"), + "", + )]; + + let cfg = match resolve_effective_config(&rec, &defs, &global(None, None)) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => panic!("definition exists"), + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + cfg.relay_mesh_model_id(), + None, + "legacy record bytes must never influence a linked instance" + ); +} + +/// A linked instance whose definition inherits a blank provider from a blank +/// global must NOT pick up the record's legacy mesh bytes either — the linked +/// path has no legacy fallback at all, by construction. +#[test] +fn linked_record_with_legacy_bytes_inherits_global_not_mesh() { + let mut rec = record(Some("d1"), None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + let defs = vec![definition("d1", None, None, "")]; + + let cfg = match resolve_effective_config(&rec, &defs, &global(Some("gpt-5"), Some("openai"))) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => panic!("definition exists"), + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("openai")); + assert_eq!(cfg.model.value.as_deref(), Some("gpt-5")); + assert_eq!(cfg.relay_mesh_model_id(), None); +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 6318a5c838..592a5cbbd9 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -81,6 +81,12 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // ambient env var must not be able to forge setup mode (NotReady) on a // Ready agent or suppress it (empty/stale payload) on a NotReady one. "BUZZ_ACP_SETUP_PAYLOAD", + // Desktop ownership markers: these brand every spawned harness with the + // launching Desktop instance. A user-supplied override would let a + // definition masquerade as a different instance or fake the nonce used + // for same-session sweep decisions. + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", ]; pub(crate) fn is_reserved_env_key(key: &str) -> bool { @@ -285,8 +291,12 @@ pub(crate) fn merged_user_env( /// Look up the live env map of `persona_id` within an already-loaded persona /// slice. Returns an empty map for standalone agents (`None`) and for links -/// to personas that no longer exist (an orphaned agent spawns from its own -/// overrides alone — same fallback the prompt/model resolution uses). +/// to personas that no longer exist. The latter is an orphaned instance, +/// which `spawn_agent_child` refuses before this is ever read at spawn time +/// (see `effective_config::resolve_effective_config`'s `OrphanedInstance` +/// arm via `require_resolved`) — an empty map here only matters for +/// non-spawn callers like readiness/hash that must still resolve +/// a slice for a record whose orphan status hasn't been checked yet. pub(crate) fn live_persona_env( personas: &[super::types::AgentDefinition], persona_id: Option<&str>, diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 57993fc040..162f447981 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -1,13 +1,14 @@ //! Global agent configuration defaults. //! //! A single `global-agent-config.json` record that applies to ALL managed -//! agents. Per-agent config always wins; global provides the lowest -//! user-settable layer below persona. +//! agents. For a linked instance, the definition wins over global; for a +//! definition-less (legacy) instance, the record's own fields win over +//! global. Global is always the lowest user-settable layer. //! //! # Precedence (low → high) //! //! ```text -//! baked build env < GLOBAL < persona < per-agent < Buzz-identity +//! baked build env < GLOBAL < definition (linked) / instance (legacy) < Buzz-identity //! ``` //! //! # Semantics @@ -38,8 +39,10 @@ use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; /// so the config vocabulary is consistent across all three tiers. /// /// `env_vars` is the lowest user-settable env layer — global < persona < agent. -/// `provider` / `model` are fallback defaults: effective provider/model = -/// `agent → persona → global → None`. +/// `provider` / `model` are fallback defaults, resolved via +/// `effective_config::resolve_effective_config`: for a linked instance, +/// definition → global (the record's own `provider`/`model` bytes are never +/// consulted); for a definition-less instance, instance → global. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct GlobalAgentConfig { /// Global env vars injected into ALL agents unconditionally. @@ -204,44 +207,28 @@ pub fn save_global_agent_config(app: &AppHandle, config: &GlobalAgentConfig) -> atomic_write_json_restricted(&path, &payload) } -/// Resolve the effective model and provider for an agent using the -/// precedence chain: `agent record → linked persona → global defaults → None`. +/// Resolve the effective model and provider for an agent. /// -/// This is the single source of truth used by readiness evaluation, spawn, -/// and deploy-payload construction. All three paths must use this function so -/// they agree on what model/provider the agent will actually run with. +/// Delegates to `effective_config::resolve_effective_config` which enforces +/// definition-authoritative semantics for linked instances: +/// - **Linked:** definition → global. Record bytes are never consulted. +/// - **Definition-less:** instance → global. +/// - **Orphaned:** returns `(None, None)`. This function is a display/ +/// readiness/hash convenience, not the spawn gate — an orphan must never +/// reach a real spawn regardless of what this returns, because +/// `spawn_agent_child` refuses orphans itself via +/// `effective_config::resolve_effective_config(...).require_resolved()` +/// before resolving model/provider/prompt for the process env. /// -/// # Arguments -/// * `record` — the `ManagedAgentRecord` (may have `None` for model/provider) -/// * `personas` — all current persona records (looked up by `record.persona_id`) -/// * `global` — global agent config defaults -/// -/// # Returns -/// `(effective_model, effective_provider)` — both `Option<&str>`. -pub(crate) fn resolve_effective_model_provider<'a>( - record: &'a ManagedAgentRecord, - personas: &'a [AgentDefinition], - global: &'a GlobalAgentConfig, -) -> (Option<&'a str>, Option<&'a str>) { - let (persona_model, persona_provider) = record - .persona_id - .as_deref() - .and_then(|pid| personas.iter().find(|p| p.id == pid)) - .map(|p| (p.model.as_deref(), p.provider.as_deref())) - .unwrap_or((None, None)); - - let effective_model = record - .model - .as_deref() - .or(persona_model) - .or(global.model.as_deref()); - let effective_provider = record - .provider - .as_deref() - .or(persona_provider) - .or(global.provider.as_deref()); - - (effective_model, effective_provider) +/// Returns owned `String`s because the effective value may come from a +/// tier that outlives none of the input borrows (e.g. global config). +pub(crate) fn resolve_effective_model_provider( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> (Option, Option) { + super::effective_config::resolve_effective_model_provider_pair(record, personas, global) + .unwrap_or((None, None)) } #[cfg(test)] 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 6eb4338034..553596e226 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![], @@ -376,15 +380,14 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini } } -/// Tier 1 — agent record wins: record has explicit model/provider; they must -/// outrank both the linked persona and the global defaults. Fails against any -/// implementation that prefers global or persona over the record. +/// Linked instance: definition (persona) wins — stale record bytes are +/// ignored. This is the core model-inheritance fix. #[test] -fn resolve_agent_record_wins_over_persona_and_global() { +fn resolve_definition_wins_over_stale_record_for_linked_instance() { let mut record = bare_record(); record.persona_id = Some("p1".to_string()); - record.model = Some("record-model".to_string()); - record.provider = Some("record-provider".to_string()); + record.model = Some("stale-record-model".to_string()); + record.provider = Some("stale-record-provider".to_string()); let personas = vec![persona( "p1", Some("persona-model"), @@ -398,11 +401,15 @@ fn resolve_agent_record_wins_over_persona_and_global() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model"), "record model must win"); assert_eq!( - provider, - Some("record-provider"), - "record provider must win" + model.as_deref(), + Some("persona-model"), + "definition model must win over stale record" + ); + assert_eq!( + provider.as_deref(), + Some("persona-provider"), + "definition provider must win over stale record" ); } @@ -428,12 +435,12 @@ fn resolve_persona_fallback_when_record_has_none() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("persona-model"), "persona model must be used when record has none" ); assert_eq!( - provider, + provider.as_deref(), Some("persona-provider"), "persona provider must be used when record has none" ); @@ -458,12 +465,12 @@ fn resolve_global_fallback_when_record_and_persona_have_none() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("global-model"), "global model must be used when record and persona have none" ); assert_eq!( - provider, + provider.as_deref(), Some("global-provider"), "global provider must be used when record and persona have none" ); @@ -519,8 +526,8 @@ fn resolve_global_fallback_when_no_persona_linked() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("global-model")); - assert_eq!(provider, Some("global-provider")); + assert_eq!(model.as_deref(), Some("global-model")); + assert_eq!(provider.as_deref(), Some("global-provider")); } /// All-None: no source provides model/provider → both must be None. @@ -543,18 +550,15 @@ fn resolve_all_none_when_no_source_provides_values() { ); } -/// Partial tier — record has model but not provider; persona has provider but -/// not model; global has both. Each field resolves independently through the -/// three-tier chain. +/// Each field resolves independently: definition model=None → global fills +/// model; definition has provider → definition wins for provider. Stale +/// record bytes are ignored for linked instances. #[test] fn resolve_each_field_resolves_independently_through_tiers() { let mut record = bare_record(); record.persona_id = Some("p1".to_string()); - record.model = Some("record-model".to_string()); - // record.provider = None → falls through to persona + record.model = Some("stale-record-model".to_string()); let personas = vec![persona("p1", None, Some("persona-provider"))]; - // persona.model = None → global fills model if record also had none, but - // record has model here so global is not needed for model. let global = GlobalAgentConfig { model: Some("global-model".to_string()), provider: Some("global-provider".to_string()), @@ -563,11 +567,15 @@ fn resolve_each_field_resolves_independently_through_tiers() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model"), "record wins for model"); assert_eq!( - provider, + model.as_deref(), + Some("global-model"), + "definition model=None → global fills model; stale record ignored" + ); + assert_eq!( + provider.as_deref(), Some("persona-provider"), - "persona wins for provider when record has none" + "definition provider wins" ); } @@ -620,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 e4b4c36967..ffcdea8181 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 66f3d882b9..be9b07cf11 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -7,7 +7,9 @@ pub(crate) use agent_env::{ }; mod backend; pub(crate) mod config_bridge; +pub(crate) mod custom_harnesses; mod discovery; +pub(crate) mod effective_config; mod env_vars; pub(crate) mod git_bash; pub(crate) mod global_config; @@ -27,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; @@ -59,7 +62,8 @@ pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; pub(crate) use readiness::{ - agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, + agent_readiness, resolve_effective_agent_env, resolve_effective_harness_descriptor, + AgentReadiness, Requirement, }; pub use relay_mesh::*; pub use repos::{ diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e13ab2baad..c8f008836d 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 4; +const NEST_SKILL_VERSION: u32 = 5; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a959381603..031b049a49 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -29,6 +29,18 @@ fn init_nest_dir_prod_sets_buzz() { } } +#[test] +fn nest_skill_contains_safe_mention_workflow() { + assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); + assert!(BUZZ_CLI_SKILL_MD.contains("every presentation-only name that should notify")); + assert!(BUZZ_CLI_SKILL_MD + .contains("permits unresolved or ambiguous `@Name` text as presentation-only")); + assert!(BUZZ_CLI_SKILL_MD.contains("signed event's `mention_pubkeys`")); + assert!(BUZZ_CLI_SKILL_MD.contains("no follow-up verification command is needed")); + assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized")); + assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); @@ -422,8 +434,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 +494,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/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index fefdfa77fa..79a5ea301d 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,14 +87,11 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**Mentions that notify:** Use `@Name` directly in message content — the CLI auto-resolves channel members by name and adds the required p-tags. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to p-tags without needing a flag. +**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. ```bash -# ✅ Correct — notification delivered automatically -buzz messages send --channel --content "@Alice check this" - -# Multiple mentions — same pattern -buzz messages send --channel --content "@Alice @Bob review please" +buzz messages send --channel \ + --content "@Alice check this" --mention ``` ## DM Management diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 2033003bc8..ea61a811db 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())); @@ -359,10 +400,14 @@ pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { } /// A persona's spawn-relevant config, pinned onto a `ManagedAgentRecord` at -/// create time. After the snapshot, spawn and deploy read these fields off the -/// record and never the live persona, so an agent stays pinned to the config -/// it was created with — restart reuses the snapshot, delete+respawn rewrites -/// it. +/// create time for display/backward-compat purposes. For a linked instance, +/// spawn and deploy do NOT read these snapshotted fields — they resolve +/// model/provider/prompt live from the definition on every spawn (see +/// `effective_config::resolve_effective_config`), so a definition edit +/// propagates on the next restart without delete+respawn. The snapshot still +/// matters for `runtime` (materialized per B5, no live-read path yet) and for +/// `persona_source_version`, the drift basis the Agents menu compares against +/// the definition's current content hash. pub struct PersonaSnapshot { pub system_prompt: Option, pub model: Option, @@ -377,24 +422,6 @@ pub struct PersonaSnapshot { pub source_version: String, } -/// Apply persona-wins-when-set precedence for a single optional string field. -/// -/// Returns the persona's value when it is non-`None` and non-whitespace-only; -/// otherwise falls back to the record's value with the same blank filter applied. -/// Returns `None` only when both are blank — a genuinely unconfigured field stays -/// unconfigured. -/// -/// This is the single source of truth for the precedence rule used by -/// `persona_snapshot_with_agent_config_fallback`, `build_deploy_payload`, and -/// `resolve_effective_prompt_model_provider` so the three paths cannot drift. -pub fn persona_field_with_record_fallback( - persona_value: Option<&str>, - record_value: Option<&str>, -) -> Option { - let non_blank = |v: Option<&str>| v.filter(|s| !s.trim().is_empty()).map(str::to_owned); - non_blank(persona_value).or_else(|| non_blank(record_value)) -} - /// Build the pinned snapshot for an agent created from `persona`. /// /// The persona's `system_prompt` is always present, so it is wrapped in @@ -411,51 +438,14 @@ pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot { } } -/// Build the pinned snapshot for an **existing** agent record being re-snapshotted -/// from its linked persona (on spawn or app-launch restore). +/// Re-pin `record` to `persona`: build a snapshot via [`persona_snapshot`] +/// and mirror it onto the record — the definition quad +/// (`system_prompt`/`model`/`provider`/`runtime`), the env-override +/// self-heal, and the `persona_source_version` drift basis. /// -/// Precedence rule: when the persona sets `model` or `provider` (non-`None`, non-empty), -/// the persona wins — this is the expected inheritance. When the persona leaves -/// these fields blank (`None` or empty string), the agent record's own values are -/// preserved instead. This prevents a persona with no configured model/provider from -/// clobbering a value the user already set on the agent, which would trap the agent -/// in a permanent "needs configuration" loop that users cannot escape. -/// -/// `source_version` is always updated to the current persona content hash so the -/// drift badge clears correctly even when model/provider are not touched. -/// -/// Env vars are not part of the snapshot: `record.env_vars` (agent overrides) -/// is left untouched and the live persona env is merged underneath at read time. -/// -/// The two fields (`model`, `provider`) are independent: a persona that sets only -/// `model` wins on `model` while the agent's `provider` is preserved, and vice versa. -pub fn persona_snapshot_with_agent_config_fallback( - persona: &AgentDefinition, - current_agent_model: Option<&str>, - current_agent_provider: Option<&str>, -) -> PersonaSnapshot { - // Delegate system_prompt and source_version to persona_snapshot so future - // PersonaSnapshot field additions stay automatically consistent. - let base = persona_snapshot(persona); - - // Apply the shared precedence rule: persona wins when non-blank, else - // the agent record's value is preserved so a configured agent stays configured. - let model = persona_field_with_record_fallback(base.model.as_deref(), current_agent_model); - let provider = - persona_field_with_record_fallback(base.provider.as_deref(), current_agent_provider); - - PersonaSnapshot { - model, - provider, - ..base - } -} - -/// Re-pin `record` to `persona`: build the snapshot (via -/// [`persona_snapshot_with_agent_config_fallback`], so blank persona -/// `model`/`provider` preserve the record's own values) and mirror it onto the -/// record — the definition quad (`system_prompt`/`model`/`provider`/`runtime`), -/// the env-override self-heal, and the `persona_source_version` drift basis. +/// Definition-authoritative: blank definition model/provider produce `None` +/// on the record. The effective-config resolver falls through to the global +/// default at read time; stale materialized record bytes are never preserved. /// /// This is the single apply used by every snapshot-apply site: the spawn /// re-pin (`start_local_agent_with_preflight`), the launch backfill and @@ -467,11 +457,7 @@ pub fn persona_snapshot_with_agent_config_fallback( /// caller's concern, and `spawn_config_hash` (which applies this to a clone) /// must stay pure. pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { - let snapshot = persona_snapshot_with_agent_config_fallback( - persona, - record.model.as_deref(), // fallback: record.model - record.provider.as_deref(), // fallback: record.provider - ); + let snapshot = persona_snapshot(persona); if let Some(prompt) = snapshot.system_prompt { record.system_prompt = Some(prompt); } @@ -507,5 +493,33 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe .retain(|k, v| persona.env_vars.get(k) != Some(v)); record.persona_source_version = Some(snapshot.source_version); } + +/// Preview what `record` would look like immediately after the start/restore +/// paths re-pin it to its linked persona, without mutating `record` itself. +/// +/// Every decision made ahead of the real re-pin — the relay-mesh preflight in +/// `start_local_agent_with_preflight`, the restart-badge hash in +/// `spawn_config_hash` — needs to reason about spawn-time state, not +/// pre-snapshot bytes, so a persona edit that flips a field (e.g. `provider` +/// to/from relay-mesh) between saves is reflected in the decision instead of +/// the stale value the real [`apply_persona_snapshot`] is about to overwrite +/// anyway. Idempotent: applying it to an already-current record is a no-op, +/// so the spawn-time stamp and later recomputes agree when nothing changed. +/// +/// Orphaned records (persona deleted) pass through unchanged: the caller's +/// own orphan handling — refusing to spawn, hashing as `(None, None, None)` +/// — runs on the real record downstream, not on this preview. +pub fn preview_prospective_persona_snapshot( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> ManagedAgentRecord { + let mut preview = record.clone(); + if let Some(persona_id) = preview.persona_id.clone() { + if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { + apply_persona_snapshot(&mut preview, persona); + } + } + preview +} #[cfg(test)] mod tests; 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 d62cc5b4bb..b9542f9a87 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -1,4 +1,143 @@ use super::*; +use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; + +/// A linked instance record with no persona-derived fields set yet — the +/// state right after creation, before any snapshot apply. +fn sample_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".repeat(64), + name: "agent".into(), + persona_id: Some("test-persona".into()), + private_key_nsec: "nsec1fake".into(), + auth_tag: None, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +// ── preview_prospective_persona_snapshot (Finding 4: relay-mesh preflight +// ordering) ─────────────────────────────────────────────────────────────── + +/// Regression for the relay-mesh preflight-ordering defect: the preflight +/// used to check `ensure_relay_mesh_for_record` against the record's stale +/// pre-snapshot bytes, so a persona edit that flips `provider` to +/// `relay-mesh` between saves would not trigger the mesh preflight on the +/// very next start — only on the restart after that, once the real +/// `apply_persona_snapshot` had already landed. The preview must reflect the +/// same prospective re-snapshot the real spawn path applies. +#[test] +fn preview_reflects_persona_edit_flipping_provider_into_relay_mesh() { + let record = sample_record(); // provider: None (not relay-mesh) + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("relay-mesh".into()); + persona.model = Some("auto".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!( + preview.provider.as_deref(), + Some("relay-mesh"), + "preview must carry the persona's current provider, not the record's stale None" + ); + assert_eq!(preview.model.as_deref(), Some("auto")); +} + +/// Preview reflects a persona edit flipping AWAY from relay-mesh too — +/// symmetric with the into-mesh case above. +#[test] +fn preview_reflects_persona_edit_flipping_provider_out_of_relay_mesh() { + let mut record = sample_record(); + record.provider = Some("relay-mesh".into()); + record.model = Some("auto".into()); + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("anthropic".into()); + persona.model = Some("claude-opus-4".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!(preview.provider.as_deref(), Some("anthropic")); + assert_eq!(preview.model.as_deref(), Some("claude-opus-4")); +} + +/// The preview must not mutate the record passed in — callers (the mesh +/// preflight) need the original bytes intact for the real snapshot apply +/// that follows. +#[test] +fn preview_does_not_mutate_input_record() { + let record = sample_record(); + let original = record.clone(); + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("relay-mesh".into()); + + let _ = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!(record.provider, original.provider); + assert_eq!(record.model, original.model); +} + +/// Orphaned instance (persona deleted): the preview must pass the record +/// through unchanged so downstream orphan handling (refuse to spawn) sees +/// the same bytes it always would. +#[test] +fn preview_passes_through_unchanged_when_persona_missing() { + let mut record = sample_record(); + record.provider = Some("anthropic".into()); + record.persona_id = Some("deleted-persona".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[]); + + assert_eq!(preview.provider.as_deref(), Some("anthropic")); + assert_eq!(preview.persona_id.as_deref(), Some("deleted-persona")); +} fn sample_persona() -> AgentDefinition { AgentDefinition { @@ -12,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(), @@ -113,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(); @@ -218,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(), @@ -247,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(), @@ -342,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(), @@ -384,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, @@ -437,43 +605,6 @@ fn persona_content_hash_changes_on_edit() { ); } -// ── persona_field_with_record_fallback ──────────────────────────────────── - -#[test] -fn field_fallback_persona_present_wins() { - assert_eq!( - persona_field_with_record_fallback(Some("persona-model"), Some("record-model")), - Some("persona-model".to_owned()), - ); -} - -#[test] -fn field_fallback_persona_blank_uses_record() { - assert_eq!( - persona_field_with_record_fallback(None, Some("record-model")), - Some("record-model".to_owned()), - ); - assert_eq!( - persona_field_with_record_fallback(Some(" "), Some("record-model")), - Some("record-model".to_owned()), - ); -} - -#[test] -fn field_fallback_both_blank_is_none() { - assert_eq!(persona_field_with_record_fallback(None, None), None); - assert_eq!(persona_field_with_record_fallback(Some(""), Some("")), None); -} - -#[test] -fn field_fallback_record_blank_is_none() { - assert_eq!( - persona_field_with_record_fallback(None, Some(" ")), - None, - "whitespace-only record value must also be treated as blank" - ); -} - // ── PersonaSnapshot.runtime ─────────────────────────────────────────────── /// (b) The snapshot carries the persona's runtime VERBATIM — including None, @@ -484,7 +615,7 @@ fn field_fallback_record_blank_is_none() { #[test] fn snapshot_runtime_verbatim_from_persona() { let persona = sample_persona(); // runtime = Some("goose") - let snap = persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4"), Some("openai")); + let snap = persona_snapshot(&persona); assert_eq!( snap.runtime.as_deref(), Some("goose"), @@ -493,15 +624,14 @@ fn snapshot_runtime_verbatim_from_persona() { let mut no_runtime = sample_persona(); no_runtime.runtime = None; - let snap = - persona_snapshot_with_agent_config_fallback(&no_runtime, Some("gpt-4"), Some("openai")); + let snap = persona_snapshot(&no_runtime); assert_eq!( snap.runtime, None, "persona runtime None must produce None snapshot (clears stale materialized value)" ); } -// ── persona_snapshot_with_agent_config_fallback ──────────────────────────── +// ── persona_snapshot (definition-authoritative) ────────────────────────── /// Helper: a persona with no model/provider configured. fn blank_model_persona() -> AgentDefinition { @@ -512,25 +642,22 @@ fn blank_model_persona() -> AgentDefinition { } } -/// (a) Persona leaves model/provider blank, agent record has values → -/// record values preserved AND source_version still updated to current hash. +/// Definition-authoritative: blank definition model/provider → snapshot is +/// None. The effective-config resolver handles global fallback at read time. #[test] -fn fallback_preserves_record_values_when_persona_blank() { +fn blank_definition_clears_model_provider() { let persona = blank_model_persona(); let expected_version = persona_content_hash(&persona_event_content(&persona)); - let snapshot = - persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4o"), Some("openai")); + let snapshot = persona_snapshot(&persona); - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "blank persona model must fall back to agent record value" + assert!( + snapshot.model.is_none(), + "blank definition model must produce None" ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "blank persona provider must fall back to agent record value" + assert!( + snapshot.provider.is_none(), + "blank definition provider must produce None" ); assert_eq!( snapshot.source_version, expected_version, @@ -538,124 +665,102 @@ fn fallback_preserves_record_values_when_persona_blank() { ); } -/// (b) Persona has model/provider set → persona wins over agent record. +/// Persona has model/provider set → snapshot carries them verbatim. #[test] -fn fallback_persona_wins_when_set() { +fn snapshot_carries_persona_model_provider() { let persona = sample_persona(); // has model=Some("claude-opus-4"), provider=Some("anthropic") - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // agent had a different model - Some("openai"), // agent had a different provider - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), Some("claude-opus-4"), - "persona model must win when persona has a value" + "persona model must be carried verbatim" ); assert_eq!( snapshot.provider.as_deref(), Some("anthropic"), - "persona provider must win when persona has a value" + "persona provider must be carried verbatim" ); } -/// (c) Both blank → snapshot keeps None; a genuinely unconfigured agent +/// Both blank → snapshot keeps None; a genuinely unconfigured agent /// stays unconfigured (no fabricated values). #[test] -fn fallback_both_blank_stays_none() { +fn both_blank_stays_none() { let persona = blank_model_persona(); - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, None, // agent also has no model - None, // agent also has no provider - ); + let snapshot = persona_snapshot(&persona); assert!( snapshot.model.is_none(), - "neither persona nor agent has model — snapshot must be None" + "blank persona model → snapshot None" ); assert!( snapshot.provider.is_none(), - "neither persona nor agent has provider — snapshot must be None" + "blank persona provider → snapshot None" ); } -/// Whitespace-only values on the persona are treated as blank; agent -/// fallback applies. +/// Whitespace-only values on the definition are preserved verbatim in the +/// snapshot. The effective-config resolver treats whitespace as blank. #[test] -fn fallback_treats_whitespace_only_persona_value_as_blank() { +fn whitespace_only_definition_preserved_verbatim() { let mut persona = sample_persona(); persona.model = Some(" ".to_string()); persona.provider = Some("\t".to_string()); - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("claude-opus-4"), - Some("anthropic"), - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), - Some("claude-opus-4"), - "whitespace-only persona model must be treated as blank" + Some(" "), + "whitespace-only model is preserved verbatim in snapshot" ); assert_eq!( snapshot.provider.as_deref(), - Some("anthropic"), - "whitespace-only persona provider must be treated as blank" + Some("\t"), + "whitespace-only provider is preserved verbatim in snapshot" ); } -/// Cross-field independence: persona sets model but not provider → model -/// comes from persona, provider falls back to the record. This is the -/// practically common case (model-only personas). +/// Cross-field independence: definition sets model but not provider → model +/// comes from definition, provider is None (global fallback at read time). #[test] -fn fallback_persona_model_set_provider_blank_uses_record_provider() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.provider = None; // blank provider on persona +fn definition_model_set_provider_blank_produces_none_provider() { + let mut persona = sample_persona(); + persona.provider = None; - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be overridden by persona) - Some("openai"), // record provider (should be preserved) - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), Some("claude-opus-4"), - "persona model must win when persona has a value" + "definition model must be used" ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "record provider must be used when persona provider is blank" + assert!( + snapshot.provider.is_none(), + "definition provider=None → snapshot None" ); } -/// Inverse: persona sets provider but not model → provider comes from -/// persona, model falls back to the record. +/// Definition provider set, model blank → model=None (global fallback at +/// read time), provider from definition. #[test] -fn fallback_persona_provider_set_model_blank_uses_record_model() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.model = None; // blank model on persona +fn definition_provider_set_model_blank_produces_none_model() { + let mut persona = sample_persona(); + persona.model = None; - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be preserved) - Some("openai"), // record provider (should be overridden by persona) - ); + let snapshot = persona_snapshot(&persona); - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "record model must be used when persona model is blank" + assert!( + snapshot.model.is_none(), + "definition model=None → snapshot None" ); assert_eq!( snapshot.provider.as_deref(), Some("anthropic"), - "persona provider must win when persona has a value" + "definition provider must be used" ); } @@ -806,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 b0d874dc78..9bf7ab74b0 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 e924345e8b..387b4d72c6 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 c04dfa88a4..c053d933c5 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -47,6 +47,7 @@ use crate::managed_agents::{ discovery::{known_acp_runtime, KnownAcpRuntime}, env_vars::merged_user_env, global_config::GlobalAgentConfig, + normalize_agent_args, types::{AcpAvailabilityStatus, AgentDefinition, ManagedAgentRecord}, }; @@ -77,6 +78,101 @@ pub(crate) struct EffectiveAgentEnv { pub effective_command: String, } +// ── Typed effective-harness descriptor ─────────────────────────────────────── +// +// A single owned type that fully describes what a spawn would run. Produced +// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, +// spawn_config_hash, build_managed_agent_summary, get_agent_models, and +// agent_readiness — so the harness-definition lookup and arg/env resolution +// happen exactly once, in one place. + +/// The complete effective description of a harness spawn: resolved command, +/// args, and layered env. This is the single source of truth for what will +/// actually run — computed once and shared across every consumer that needs +/// the effective values. +#[derive(Debug, Clone)] +pub(crate) struct EffectiveHarnessDescriptor { + /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). + /// Used for `known_acp_runtime` lookup and hashing. + pub command: String, + /// Normalized effective args. Instance args win when non-empty; otherwise + /// the harness definition's args apply. + pub args: Vec, + /// The full layered process env: baked floor → runtime metadata → definition + /// env → global → persona → agent. + pub env: BTreeMap, +} + +/// Resolve the complete harness descriptor from a record + context — the single +/// authoritative path for command, args, and env. +/// +/// This is the only place where harness-definition lookup and arg/env layering +/// happen; spawn, hash, summary, and both model-probe paths all consume this. +/// +/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked +/// persona) references a runtime id that no longer exists in the registry — +/// the same typed error produced by `try_record_agent_command`. Callers that +/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) +/// propagate the error; callers that degrade gracefully may use +/// `.unwrap_or_else(|_| …)`. +/// +/// Does NOT require an `AppHandle` so it is fully unit-testable. +/// +/// # Arguments +/// * `record` — the managed agent record +/// * `personas` — all current personas (for command/env resolution) +/// * `global` — global agent config defaults +pub(crate) fn resolve_effective_harness_descriptor( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; + let runtime_meta = known_acp_runtime(&effective_command); + + // Look up the harness definition once — used for both args and env. + // Resolution order: record.runtime → persona.runtime → "". + let harness_def = { + let runtime_id = record + .runtime + .as_deref() + .or_else(|| { + record.persona_id.as_deref().and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.runtime.as_deref()) + }) + }) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + }; + + // Args: explicit non-empty instance args win; otherwise use definition args. + let args = { + let record_args = record.agent_args.clone(); + let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); + if instance_has_args { + normalize_agent_args(&effective_command, record_args) + } else if let Some(ref def) = harness_def { + normalize_agent_args(&effective_command, def.args.clone()) + } else { + normalize_agent_args(&effective_command, record_args) + } + }; + + // Env: full layered resolution (same as resolve_effective_agent_env). + // Pass harness_def directly to avoid a second lookup. + let effective_env = + resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); + + Ok(EffectiveHarnessDescriptor { + command: effective_command, + args, + env: effective_env.env, + }) +} + /// Assemble the effective agent env from a record, personas, optional /// known-runtime metadata, and the global agent config defaults — without an /// `AppHandle` so it is fully unit-testable. @@ -92,17 +188,44 @@ pub(crate) fn resolve_effective_agent_env( personas: &[AgentDefinition], runtime: Option<&KnownAcpRuntime>, global: &GlobalAgentConfig, +) -> EffectiveAgentEnv { + // Look up the harness definition for definition-level env (preset/custom). + // Same resolution logic as spawn_agent_child: record runtime id first, then + // persona runtime id, then nothing. + let harness_def = { + let runtime_id = record + .runtime + .as_deref() + .or_else(|| { + record.persona_id.as_deref().and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.runtime.as_deref()) + }) + }) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + }; + + resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def) +} + +/// Inner implementation that accepts a pre-fetched `harness_def` to avoid a +/// second registry lookup when the caller (e.g. `resolve_effective_harness_descriptor`) +/// already has the definition in hand. +fn resolve_effective_agent_env_with_def( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + runtime: Option<&KnownAcpRuntime>, + global: &GlobalAgentConfig, + harness_def: Option>, ) -> EffectiveAgentEnv { let effective_command = crate::managed_agents::record_agent_command(record, personas); // Layer 1: baked build defaults (floor — internal builds only; OSS = empty). let mut env = baked_build_env(); - // Layer 2: runtime metadata env vars (model / provider keys derived from - // the record's structured fields, with global as fallback). - // - // Uses the shared resolver to guarantee readiness and spawn agree on the - // effective model/provider: agent → persona → global → None. let (effective_model, effective_provider) = super::global_config::resolve_effective_model_provider(record, personas, global); @@ -111,13 +234,24 @@ pub(crate) fn resolve_effective_agent_env( rt.model_env_var, rt.provider_env_var, rt.provider_locked, - effective_model, - effective_provider, + effective_model.as_deref(), + effective_provider.as_deref(), ) { env.insert(key.to_string(), value.to_string()); } } + // Layer 2b: definition env — the harness author's defaults (e.g. CURSOR_ACP=1). + // Applied as a floor below global so user env always wins on collision. + // Reserved keys are stripped by the shared `is_reserved_env_key` predicate. + if let Some(ref def) = harness_def { + for (key, value) in &def.env { + if !super::env_vars::is_reserved_env_key(key) { + env.insert(key.clone(), value.clone()); + } + } + } + // Layer 3a: global env vars — the lowest user-settable layer. // Injected before persona/agent so per-agent values win on collision. // `merged_user_env` with an empty "lower" map applies reserved/malformed-key @@ -138,7 +272,11 @@ pub(crate) fn resolve_effective_agent_env( // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. #[cfg(feature = "mesh-llm")] - super::apply_relay_mesh_env(&mut env, effective_provider, effective_model); + super::apply_relay_mesh_env( + &mut env, + effective_provider.as_deref(), + effective_model.as_deref(), + ); EffectiveAgentEnv { env, @@ -196,6 +334,14 @@ pub enum Requirement { /// Git for Windows is missing, so buzz-agent cannot launch buzz-dev-mcp's /// Bash-based shell tool. Doctor owns installation and re-checking. GitBash, + /// A custom harness command that cannot be resolved in the current PATH. + /// Displayed as a PATH badge in the harness card and a nudge in the agent + /// message stream. No in-app action can fix this — the user must install + /// the binary or update their PATH. + MissingBinary { + /// The command name that was not found (e.g. `\"my-acp-agent\"`). + command: String, + }, } // ── AgentReadiness ──────────────────────────────────────────────────────────── @@ -271,7 +417,13 @@ fn collect_missing_requirements( runtime: Option<&KnownAcpRuntime>, ) -> Vec { let Some(rt) = runtime else { - // Unknown/custom command — no requirements to check. + // Unknown/custom command — check that the binary is actually resolvable. + // No known requirement set beyond the PATH check. + if crate::managed_agents::resolve_command(&effective.effective_command).is_none() { + return vec![Requirement::MissingBinary { + command: effective.effective_command.clone(), + }]; + } return vec![]; }; @@ -1207,10 +1359,30 @@ mod tests { #[test] fn unknown_command_is_always_ready() { - let env = make_env("my-custom-harness", BTreeMap::new()); + // Since Phase B-7 (readiness exec-check), unknown/custom commands that are + // not resolvable in PATH produce a MissingBinary requirement rather than + // being unconditionally Ready. A command that IS resolvable should be Ready. + // Use a known-present binary so the test is not environment-sensitive. + let env = make_env("sh", BTreeMap::new()); assert!( agent_readiness(&env).is_ready(), - "unknown/custom command should always be Ready (no requirements)" + "unknown/custom command present in PATH should be Ready" + ); + } + + #[test] + fn unknown_command_missing_from_path_is_not_ready() { + let env = make_env("my-custom-harness-that-does-not-exist", BTreeMap::new()); + let readiness = agent_readiness(&env); + assert!( + !readiness.is_ready(), + "unknown/custom command absent from PATH should be NotReady" + ); + let reqs = readiness.requirements(); + assert_eq!(reqs.len(), 1); + assert!( + matches!(&reqs[0], Requirement::MissingBinary { command } if command == "my-custom-harness-that-does-not-exist"), + "should surface MissingBinary requirement" ); } @@ -1338,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 dc73bc9739..90f05c5750 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 5af05f99d6..c9269dbf00 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/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index a47122df80..7a6f5b094a 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,10 +1,9 @@ -#[cfg(feature = "mesh-llm")] -use super::{ManagedAgentRecord, RelayMeshConfig}; - pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; +#[cfg(feature = "mesh-llm")] +pub const RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV: &str = "BUZZ_AGENT_PREFER_MESH_FOR_AUTO"; /// Translate the native Buzz shared compute provider into the OpenAI-compatible /// transport understood by buzz-agent. These are derived runtime details, not @@ -35,6 +34,14 @@ pub fn apply_relay_mesh_env( RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), ); env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string()); + // Buzz owns the meaning of relay-mesh `auto`: buzz-agent dynamically uses + // mesh-llm's virtual Mixture-of-Agents model whenever the live catalog says + // at least two distinct models are available, and otherwise keeps the + // router's normal single-model `auto` behavior. + env.insert( + RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), + "1".to_string(), + ); // Keep the requested response inside smaller local-model context windows, // and spend that budget on an answer/tool call instead of hidden reasoning. // Without both settings Qwen3 either fails the router's fit check at the @@ -46,146 +53,11 @@ pub fn apply_relay_mesh_env( env.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "none".to_string()); } -/// Resolve a record's relay-mesh config, typed field first. -/// -/// Source of truth is the typed `record.relay_mesh` field. For records saved -/// before that field existed, fall back to detecting the relay-mesh preset -/// from `env_vars` (the legacy discriminator). New records carry the typed -/// field and need no env-var sniffing at all. -#[cfg(feature = "mesh-llm")] -pub fn relay_mesh_config(record: &ManagedAgentRecord) -> Option { - if record.provider.as_deref() == Some(RELAY_MESH_PROVIDER_ID) { - let model_ref = record - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(); - return Some(RelayMeshConfig { model_ref }); - } - if let Some(config) = &record.relay_mesh { - return Some(config.clone()); - } - relay_mesh_model_id_from_env(record).map(|model_ref| RelayMeshConfig { model_ref }) -} - -/// Returns the relay-mesh model id for agents whose provider env points at the -/// local mesh client endpoint created by Buzz's relay-mesh preset. -/// -/// Prefer [`relay_mesh_config`]; this remains as a convenience for call sites -/// that only need the model id. -#[cfg(feature = "mesh-llm")] -pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option { - relay_mesh_config(record).map(|config| config.model_ref) -} - -/// Legacy env-var discriminator: detects the relay-mesh preset purely from the -/// four preset env vars. Used as a fallback for records saved before the typed -/// `relay_mesh` field existed. -#[cfg(feature = "mesh-llm")] -fn relay_mesh_model_id_from_env(record: &ManagedAgentRecord) -> Option { - let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim(); - if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL { - return None; - } - let provider = record.env_vars.get("BUZZ_AGENT_PROVIDER")?.trim(); - if provider != "openai" { - return None; - } - let api_key = record.env_vars.get("OPENAI_COMPAT_API_KEY")?.trim(); - if api_key != RELAY_MESH_API_KEY_PLACEHOLDER { - return None; - } - record - .env_vars - .get("OPENAI_COMPAT_MODEL") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - #[cfg(all(test, feature = "mesh-llm"))] mod tests { use std::collections::BTreeMap; use super::*; - use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; - - fn fixture() -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag: Some("tag".into()), - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - env_vars: BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::Local, - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - persona_source_version: None, - provider: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::OwnerOnly, - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } - } - - #[test] - fn relay_mesh_model_id_detects_mesh_preset_env() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1/".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } #[test] fn native_provider_uses_context_safe_non_reasoning_budget() { @@ -204,96 +76,10 @@ mod tests { env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), Some("none") ); - } - - #[test] - fn relay_mesh_model_id_ignores_non_mesh_openai_env() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "https://api.openai.com/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()), - ]); - - assert_eq!(relay_mesh_model_id(&rec), None); - } - - #[test] - fn relay_mesh_model_id_ignores_user_openai_on_same_local_port() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()), - ]); - - assert_eq!(relay_mesh_model_id(&rec), None); - } - - #[test] - fn native_provider_fields_are_authoritative() { - // The whole point: a typed record needs no env-var sniffing to be - // recognized as a relay-mesh agent. - let mut rec = fixture(); - rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); - rec.model = Some("Qwen3".to_string()); - assert!(rec.env_vars.is_empty()); assert_eq!( - relay_mesh_config(&rec), - Some(RelayMeshConfig { - model_ref: "Qwen3".to_string() - }) + env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) + .map(String::as_str), + Some("1") ); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } - - #[test] - fn native_provider_fields_win_over_legacy_config() { - let mut rec = fixture(); - rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); - rec.model = Some("native-model".to_string()); - rec.relay_mesh = Some(RelayMeshConfig { - model_ref: "typed-model".to_string(), - }); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "env-model".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("native-model")); - } - - #[test] - fn legacy_record_falls_back_to_env_sniff() { - // Records saved before the typed field still resolve via env vars. - let mut rec = fixture(); - rec.relay_mesh = None; - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); } } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index fab481e12b..1910620159 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "mesh-llm")] -use super::relay_mesh_model_id; use super::{ find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, @@ -36,10 +34,9 @@ type AgentSpawnResult = (String, SpawnOutcome); /// `model`/`provider` were clobbered by the old unconditional snapshot code before /// this fix — are skipped here; they self-heal on the next manual start via the /// start-path re-snapshot in `start_local_agent_with_preflight`. -/// If the linked persona is gone, we log loudly and leave the snapshot empty — -/// the record's own `system_prompt`/`model` (possibly empty for persona-created -/// agents) is then all the config that remains, which is the same fallback an -/// orphaned agent already gets. +/// If the linked persona is gone, we log loudly and leave the record untouched — +/// it stays orphaned and `spawn_agent_child` refuses to start it (see +/// `effective_config::resolve_effective_config`'s `OrphanedInstance` arm). pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let _store_guard = state @@ -66,7 +63,7 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> } let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { eprintln!( - "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving snapshot empty — it will spawn from its record fields", + "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving it orphaned — spawn will refuse it", record.pubkey ); continue; @@ -206,6 +203,9 @@ pub async fn restore_managed_agents_on_launch( continue; }; let Some(persona) = personas_for_snapshot.iter().find(|p| p.id == persona_id) else { + // Orphaned: no current persona to re-snapshot from. Leave the + // record as-is — `spawn_agent_child` (Phase B below) refuses to + // spawn it and Phase C persists the refusal to `last_error`. continue; }; super::persona_events::apply_persona_snapshot(record, persona); @@ -240,16 +240,26 @@ pub async fn restore_managed_agents_on_launch( #[cfg(feature = "mesh-llm")] let agents_to_start = { + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute. See `start_local_agent_with_preflight` + // in `commands/agents.rs` for the identical rationale on the interactive path. + let personas = load_personas(app).unwrap_or_default(); + let global = super::load_global_agent_config(app).unwrap_or_default(); let mut mesh_preflight_failures = std::collections::HashSet::new(); for record in &agents_to_start { - if relay_mesh_model_id(record).is_none() { + let mesh_model_id = super::effective_config::resolve_effective_relay_mesh_model_id( + record, &personas, &global, + ); + if mesh_model_id.is_none() { continue; } // Auto-start after relaunch: re-resolve a live bootstrap target and // dial it. Skip (with an actionable error) only when no live target // serves this model right now. if let Err(error) = - crate::commands::ensure_relay_mesh_for_record(app, record, false).await + crate::commands::ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false) + .await { persist_restore_error(app, &state, &record.pubkey, error)?; mesh_preflight_failures.insert(record.pubkey.clone()); diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 5df566dbbe..7e97fa1f56 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 0000000000..1975f5d6df --- /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 0000000000..75da221320 --- /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 6687cbbcf2..f3b4cb67fd 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -20,6 +20,12 @@ pub(crate) use path::compose_path_entries; 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, resolve_session_title, runtime_metadata_env_vars, + SESSION_TITLE_ENV_VAR, +}; + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -29,1287 +35,40 @@ pub(crate) use sweep::sweep_untracked_bundle_harnesses; type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); -/// Binary name fragments for all known agent/harness processes that Buzz -/// may spawn. Used by `process_belongs_to_us()` and the orphan sweep to -/// identify processes we should clean up. Both hyphenated and underscored -/// variants are listed because macOS `proc_name()` and Linux `/proc/comm` -/// may report either form depending on how the binary was built. -pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ - "buzz-acp", - "buzz_acp", - "buzz-agent", - "buzz_agent", - "claude-agent-acp", - "claude_agent_acp", - "claude-code-acp", - "claude_code_acp", - "codex-acp", - "codex_acp", - "goose", - // buzz-dev-mcp's multicall personalities (rg, tree, buzz, - // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call - // invocations — not listed here. - "buzz-dev-mcp", - "buzz_dev_mcp", -]; - -/// Script interpreters that may host managed agent wrappers (e.g. npm shims). -/// A process whose name matches here is NOT immediately claimed — it must also -/// carry `BUZZ_MANAGED_AGENT` in its environment (checked by the caller via -/// `process_has_buzz_marker()`). This avoids sweeping unrelated node processes. -pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node"]; - -/// Check if a process name matches any of our known agent binaries. -/// Uses exact match or prefix-with-separator to avoid false positives -/// (e.g. `"goose"` must not match `"mongoose"`). -fn name_matches_known_binary(name: &str) -> bool { - KNOWN_AGENT_BINARIES.iter().any(|&binary| { - name == binary || { - name.starts_with(binary) && { - let rest = &name[binary.len()..]; - rest.starts_with('-') || rest.starts_with('_') || rest.starts_with('.') - } - } - }) -} - -/// Check if a process name is a known script interpreter that may be hosting -/// a managed agent wrapper (e.g. `node` running an npm shim for `codex-acp`). -/// Callers must additionally verify `BUZZ_MANAGED_AGENT` ownership. -fn name_matches_interpreter(name: &str) -> bool { - KNOWN_SCRIPT_INTERPRETERS.contains(&name) -} +mod process; +#[cfg(test)] +use process::{ + buzz_marker_entry, name_matches_interpreter, name_matches_known_binary, + terminate_runtime_receipt_with, valid_agent_runtime_receipt_with, +}; +pub(crate) use process::{ + current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running, + terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt, +}; +mod orphan_sweep; +#[cfg(target_os = "macos")] +use orphan_sweep::proc_pidinfo; +pub(crate) use orphan_sweep::{ + sweep_orphaned_agent_processes, sweep_system_agent_processes, + sweep_system_agent_processes_with_grace, +}; +#[cfg(target_os = "macos")] +use orphan_sweep::{BSDInfo, PROC_PIDTBSDINFO}; #[cfg(unix)] -pub(crate) fn process_is_running(pid: u32) -> bool { - // Use libc::kill with signal 0 instead of forking a subprocess. - // Returns true only if the process exists AND we can signal it. - // Returns false for non-existent PIDs (ESRCH) and PIDs owned by - // other users (EPERM) — callers should not interact with those. - unsafe { libc::kill(pid as i32, 0) == 0 } -} +use process::resolve_pgids_and_kill; -#[cfg(not(unix))] -pub(crate) fn process_is_running(_pid: u32) -> bool { - false -} - -/// Check if a PID belongs to a known agent process we spawned. -/// Returns false for recycled PIDs that now belong to other processes. -#[cfg(target_os = "macos")] -pub(crate) fn process_belongs_to_us(pid: u32) -> bool { - // Use proc_name() from libproc to get the process name without spawning - // a subprocess. - extern "C" { - fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; - } - let mut buf = [0u8; 1024]; - let len = unsafe { - proc_name( - pid as i32, - buf.as_mut_ptr() as *mut libc::c_void, - buf.len() as u32, - ) - }; - if len <= 0 { - return false; - } - let name = String::from_utf8_lossy(&buf[..len as usize]); - // Fall through for script interpreters (e.g. `node` hosting an npm shim): - // the caller's `process_has_buzz_marker()` check decides true ownership. - name_matches_known_binary(&name) || name_matches_interpreter(&name) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn process_belongs_to_us(pid: u32) -> bool { - // First try /proc//comm. Note: comm is truncated to 15 bytes on Linux, - // so binaries with names longer than 15 chars (e.g. "claude-agent-acp") - // will never match here. - if let Ok(name) = std::fs::read_to_string(format!("/proc/{pid}/comm")) { - if name_matches_known_binary(name.trim()) { - return true; - } - // Interpreter check: `node` is 4 bytes, never truncated. - if name_matches_interpreter(name.trim()) { - return true; - } - } - - // Fallback: read /proc//exe which is a symlink to the full binary path. - // This is not subject to the 15-byte truncation limit. - if let Ok(exe_path) = std::fs::read_link(format!("/proc/{pid}/exe")) { - if let Some(basename) = exe_path.file_name().and_then(|n| n.to_str()) { - // Fall through for script interpreters — caller checks the marker. - return name_matches_known_binary(basename) || name_matches_interpreter(basename); - } - } - - false -} - -#[cfg(not(unix))] -pub(crate) fn process_belongs_to_us(_pid: u32) -> bool { - false -} - -/// The value stamped into the `BUZZ_MANAGED_AGENT` env var of every agent we -/// spawn, identifying *which* desktop instance owns it. We use the app's bundle -/// identifier (`xyz.block.buzz.app` for release, `xyz.block.buzz.app.dev` -/// for `just dev`) because it is stable across restarts — a relaunched dev -/// instance still recognizes its own previously-spawned agents as reclaimable, -/// while never matching another instance's (e.g. a dev build never reaps a DMG -/// build's agents, and vice versa). This is what lets two Buzzs coexist on -/// one machine without one's cleanup nuking the other's agents. -pub(crate) fn current_instance_id(app: &AppHandle) -> String { - app.config().identifier.clone() -} - -/// Build the full `BUZZ_MANAGED_AGENT=` env entry we match -/// against when scanning processes. Kept here so the spawn stamp and the sweep -/// matcher can never drift apart. -fn buzz_marker_entry(instance_id: &str) -> Vec { - format!("BUZZ_MANAGED_AGENT={instance_id}").into_bytes() -} - -/// Check if a running process is one of *our* managed agents: it must carry -/// `BUZZ_MANAGED_AGENT=` in its environment, where `instance_id` -/// is this desktop instance's id. A process stamped with a *different* instance -/// id belongs to another live Buzz app and must never be reaped here. -#[cfg(target_os = "macos")] -pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { - let marker = buzz_marker_entry(instance_id); - let Some(buf) = sweep::procargs2_buffer(pid) else { - return false; - }; - - // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...] - if buf.len() < std::mem::size_of::() { - return false; - } - let mut n_args: libc::c_int = 0; - unsafe { - std::ptr::copy_nonoverlapping( - buf.as_ptr(), - &mut n_args as *mut libc::c_int as *mut u8, - std::mem::size_of::(), - ); - } - let mut pos = std::mem::size_of::(); - - // Skip exec path (scan to first null). - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - // Skip null padding between exec path and argv[0]. - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - // Skip argc argument strings. - let mut args_remaining = n_args; - while args_remaining > 0 && pos < buf.len() { - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - args_remaining -= 1; - } - // Remaining bytes are null-delimited environment strings. - buf[pos..].split(|&b| b == 0).any(|entry| entry == marker) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { - let marker = buzz_marker_entry(instance_id); - let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else { - return false; - }; - data.split(|&b| b == 0).any(|entry| entry == marker) -} - -#[cfg(not(unix))] -pub(crate) fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { - false -} - -#[cfg(unix)] -fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> { - let pgid = -(pid as i32); - - if unsafe { libc::kill(pgid, signal) } == 0 { - return Ok(()); - } - - let group_err = std::io::Error::last_os_error(); - if !process_is_running(pid) { - return Ok(()); - } - - // Some local agent trees can no longer be signalled as a process group - // (for example if the leader changed groups, or macOS returns EPERM for one - // descendant). Fall back to the leader PID so stop/delete can still recover. - if matches!( - group_err.raw_os_error(), - Some(libc::EPERM) | Some(libc::ESRCH) - ) { - if unsafe { libc::kill(pid as i32, signal) } == 0 { - return Ok(()); - } - - let leader_err = std::io::Error::last_os_error(); - if leader_err.raw_os_error() == Some(libc::ESRCH) || !process_is_running(pid) { - return Ok(()); - } - - return Err(format!("failed to {action} process {pid}: {leader_err}")); - } - - Err(format!( - "failed to {action} process group {pid}: {group_err}" - )) -} - -#[cfg(unix)] -pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { - // Try graceful shutdown first (SIGTERM to the group). - signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; - - // Wait up to 1s for graceful exit. - for _ in 0..10 { - if !process_is_running(pid) { - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - // Escalate to SIGKILL on the entire group. - signal_process_group_or_leader(pid, libc::SIGKILL, "kill")?; - - Ok(()) -} - -#[cfg(windows)] -pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { - // No job handle is available on this path (e.g. after an app restart, when - // we only recovered the PID from the record), so fall back to taskkill on - // the whole tree. - super::process_lifecycle::taskkill_tree(pid) -} - -#[cfg(not(any(unix, windows)))] -pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> { - Err("managed agent shutdown after app restart is not supported on this platform".to_string()) -} - -/// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL -/// any survivors. Uses `-pid` to kill the entire process group — if an -/// orphaned agent called `setsid()`, it IS the group leader, so this -/// reaches its children too. -#[cfg(unix)] -fn sigterm_then_sigkill(pids: &[i32]) { - // Send SIGTERM to each process group. Track whether any signal was - // actually delivered so we can skip the sleep when everything is - // already gone. - let mut any_signalled = false; - for &pid in pids { - if unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 { - any_signalled = true; - } - } - - if !any_signalled { - return; - } - - std::thread::sleep(std::time::Duration::from_millis(200)); - - for &pid in pids { - // Check if the group has any living members, not just the leader. - // kill(-pid, 0) returns 0 if ANY member of the group is signalable. - if unsafe { libc::kill(-pid, 0) } == 0 { - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - } -} - -/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, -/// and signal the groups. An orphaned grandchild (e.g. `goose` or `buzz-dev-mcp`) -/// whose harness has exited retains the harness's PGID — signaling that PGID -/// kills the entire orphaned subtree. Falls back to the candidate PID itself -/// when PGID resolution fails (process may have exited between detection and -/// kill). -#[cfg(target_os = "macos")] -fn resolve_pgids_and_kill(candidate_pids: &[i32]) { - let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); - let mut pgids = std::collections::HashSet::new(); - for &pid in candidate_pids { - let pgid = unsafe { libc::getpgid(pid) }; - if pgid > 0 { - pgids.insert(pgid); - } else { - // Process may have exited; try signaling it directly as a group. - pgids.insert(pid); - } - } - // PID-recycling guard: if a resolved PGID is alive but isn't one of our - // orphan candidates, the old harness PID was recycled by a new process - // that called setsid() — skip it to avoid killing an unrelated group. - let candidate_groups = pgids.len(); - pgids.retain(|&pgid| { - if candidate_set.contains(&pgid) { - return true; - } - let alive = unsafe { libc::kill(pgid, 0) } == 0; - !alive - }); - if pgids.is_empty() && candidate_groups > 0 { - eprintln!( - "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" - ); - } - let unique: Vec = pgids.into_iter().collect(); - sigterm_then_sigkill(&unique); -} - -/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, -/// and signal the groups. Linux variant reads PGID from /proc//stat. -#[cfg(all(unix, not(target_os = "macos")))] -fn resolve_pgids_and_kill(candidate_pids: &[i32]) { - let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); - let mut pgids = std::collections::HashSet::new(); - for &pid in candidate_pids { - if let Some(pgid) = read_pgid_linux(pid as u32) { - pgids.insert(pgid as i32); - } else { - // Process may have exited; try signaling it directly as a group. - pgids.insert(pid); - } - } - // PID-recycling guard: if a resolved PGID is alive but isn't one of our - // orphan candidates, the old harness PID was recycled by a new process - // that called setsid() — skip it to avoid killing an unrelated group. - let candidate_groups = pgids.len(); - pgids.retain(|&pgid| { - if candidate_set.contains(&pgid) { - return true; - } - let alive = unsafe { libc::kill(pgid, 0) } == 0; - !alive - }); - if pgids.is_empty() && candidate_groups > 0 { - eprintln!( - "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" - ); - } - let unique: Vec = pgids.into_iter().collect(); - sigterm_then_sigkill(&unique); -} - -pub(crate) fn valid_agent_runtime_receipt( - path: &std::path::Path, - receipt: &super::ManagedAgentRuntimeReceipt, - instance_id: &str, -) -> bool { - let Ok(canonical) = - ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) - else { - return false; - }; - canonical == receipt.key - && path.file_name().and_then(|name| name.to_str()) - == Some(&format!("{}.json", receipt.key.runtime_id())) - && receipt.desktop_instance_id == instance_id - && process_is_running(receipt.pid) - && process_belongs_to_us(receipt.pid) - && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id) -} - -fn terminate_runtime_receipt_with( - path: &std::path::Path, - receipt: &super::ManagedAgentRuntimeReceipt, - terminate: impl FnOnce(u32) -> Result<(), String>, - mut is_running: impl FnMut(u32) -> bool, - remove: impl FnOnce(&std::path::Path), -) -> Result<(), String> { - terminate(receipt.pid)?; - for _ in 0..20 { - if !is_running(receipt.pid) { - remove(path); - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - Err(format!( - "prior runtime {} for pair {} on {} did not exit", - receipt.pid, receipt.key.pubkey, receipt.key.relay_url - )) -} - -/// Replace a valid prior-session process before registering a new child for -/// the same pair. The caller must hold the runtime transition lock so receipt -/// inspection, termination, spawn, and registration cannot race shutdown or -/// another start. -pub(crate) fn terminate_untracked_pair_runtime( - app: &AppHandle, - key: &ManagedAgentRuntimeKey, -) -> Result<(), String> { - let instance_id = current_instance_id(app); - let Some((path, receipt)) = super::read_all_agent_runtime_receipts(app) - .into_iter() - .find(|(path, receipt)| { - receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) - }) - else { - return Ok(()); - }; - - terminate_runtime_receipt_with( - &path, - &receipt, - terminate_process, - process_is_running, - super::remove_agent_runtime_receipt_path, - ) -} - -/// Kill orphaned agent processes using PID file receipts. Reads all files from -/// `agent-pids/`, verifies each PID still belongs to a known agent binary, -/// then resolves each candidate's actual PGID and signals the process group. -/// Deletes the PID file after killing. -/// -/// `skip_pids` are PIDs already handled by the tracked-agent path. -#[cfg(unix)] -pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { - let legacy_entries = super::read_all_agent_pid_files(app); - let instance_id = current_instance_id(app); - let receipt_entries: Vec<_> = super::read_all_agent_runtime_receipts(app) - .into_iter() - .filter_map(|(path, receipt)| { - if valid_agent_runtime_receipt(&path, &receipt, &instance_id) { - Some((path, receipt)) - } else { - super::remove_agent_runtime_receipt_path(&path); - None - } - }) - .collect(); - // Collect live orphans AND dead-leader groups into a single kill batch. - // Dead leaders: PGID may have been recycled, but the window is narrow - // (PID files are from this session) and the cost of missing surviving - // group members outweighs the recycling risk. - let targets: Vec = legacy_entries - .iter() - .map(|(_, pid)| *pid) - .chain(receipt_entries.iter().map(|(_, receipt)| receipt.pid)) - .filter(|pid| { - if skip_pids.contains(pid) { - return false; - } - (process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid) - }) - .map(|pid| pid as i32) - .collect(); - - if !targets.is_empty() { - resolve_pgids_and_kill(&targets); - } - - // Clean up PID files for processes we just killed or that are already gone. - for (pubkey, pid) in &legacy_entries { - if skip_pids.contains(pid) { - continue; - } - if !process_is_running(*pid) || !process_belongs_to_us(*pid) { - super::remove_agent_pid_file(app, pubkey); - } - } - for (_, receipt) in &receipt_entries { - if skip_pids.contains(&receipt.pid) { - continue; - } - if !process_is_running(receipt.pid) || !process_belongs_to_us(receipt.pid) { - super::remove_agent_runtime_receipt(app, &receipt.key); - } - } -} - -#[cfg(not(unix))] -pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]) { - let _ = app; -} - -// ── macOS process-info FFI (shared by all sweep/reap functions) ────────── -// -// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`). -// All callers in this file reach it through `sweep::collect_all_pids()`. -// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that -// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without -// redefining the struct layout in two places. - -#[cfg(target_os = "macos")] -extern "C" { - pub(super) fn proc_pidinfo( - pid: libc::c_int, - flavor: libc::c_int, - arg: u64, - buffer: *mut libc::c_void, - buffersize: libc::c_int, - ) -> libc::c_int; -} - -/// Subset of `struct proc_bsdinfo` from ``. Layout verified -/// against the macOS SDK — total size 136 bytes. -#[cfg(target_os = "macos")] -#[repr(C)] -pub(super) struct BSDInfo { - _flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus - pub(super) pbi_pid: u32, // offset 12 - pub(super) pbi_ppid: u32, // offset 16 - pub(super) pbi_uid: u32, // offset 20 - _rest: [u8; 112], -} - -#[cfg(target_os = "macos")] -const _: () = assert!(std::mem::size_of::() == 136); - -#[cfg(target_os = "macos")] -pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3; - -/// Enumerate all processes on the system owned by the current user and kill any -/// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker -/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped -/// PID-file-based cleanup (e.g. agent workers spawned with their own process -/// group whose parent harness already exited and had its PID file removed), -/// while leaving another live Buzz instance's agents untouched. -#[cfg(target_os = "macos")] -pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return; - } - let my_pid = std::process::id() as i32; - let mut orphans: Vec = Vec::new(); - - for &pid in &pids { - if pid <= 0 { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) || pid == my_pid { - continue; - } - // Check binary name first (cheap proc_name call) before UID lookup. - if !process_belongs_to_us(upid) { - continue; - } - // Verify UID and PPID via proc_pidinfo. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - if !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { - continue; - } - orphans.push(pid); - } - - if !orphans.is_empty() { - eprintln!( - "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", - orphans.len() - ); - resolve_pgids_and_kill(&orphans); - } -} - -/// Read the process group ID from /proc//stat by delegating to the shared -/// stat parser in `sweep`. Keeps a single parse site for the `/proc//stat` -/// field layout. -#[cfg(all(unix, not(target_os = "macos")))] -fn read_pgid_linux(pid: u32) -> Option { - sweep::proc_stat_ppid_pgid_linux(pid).map(|(_, pgid)| pgid) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let mut orphans: Vec = Vec::new(); - let my_pid = std::process::id() as i32; - - let Ok(entries) = std::fs::read_dir("/proc") else { - return; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - // Check ownership via /proc/ metadata. - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_linux(upid, skip_pids) { - continue; - } - orphans.push(pid); - } - - if !orphans.is_empty() { - eprintln!( - "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", - orphans.len() - ); - resolve_pgids_and_kill(&orphans); - } -} - -#[cfg(not(unix))] -pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {} - -/// Periodic-sweep variant with two-tick grace: only reaps same-instance orphans -/// that were also seen orphaned on the previous tick. This prevents killing a -/// legitimately-starting agent that spawned between the skip-list snapshot and -/// the process scan. Returns the current orphan set for use as `prev_orphans` -/// on the next tick. -#[cfg(unix)] -pub(crate) fn sweep_system_agent_processes_with_grace( - instance_id: &str, - skip_pids: &[u32], - prev_orphans: &std::collections::HashSet, -) -> std::collections::HashSet { - let current = collect_same_instance_orphans(instance_id, skip_pids); - // Only reap PIDs seen orphaned on two consecutive ticks. - let confirmed: Vec = current - .iter() - .filter(|pid| prev_orphans.contains(pid)) - .map(|&pid| pid as i32) - .collect(); - if !confirmed.is_empty() { - eprintln!( - "buzz-desktop: periodic sweep confirmed {} orphaned agent process(es), cleaning up", - confirmed.len() - ); - resolve_pgids_and_kill(&confirmed); - } - current -} - -#[cfg(not(unix))] -pub(crate) fn sweep_system_agent_processes_with_grace( - _instance_id: &str, - _skip_pids: &[u32], - _prev_orphans: &std::collections::HashSet, -) -> std::collections::HashSet { - std::collections::HashSet::new() -} - -/// Collect PIDs of same-instance agent processes that appear orphaned (not in -/// `skip_pids`). Returns the set for use in two-tick grace logic — does NOT -/// kill anything. -#[cfg(target_os = "macos")] -pub(crate) fn collect_same_instance_orphans( - instance_id: &str, - skip_pids: &[u32], -) -> std::collections::HashSet { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut orphans = std::collections::HashSet::new(); - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return orphans; - } - - for &pid in &pids { - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - if !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { - continue; - } - orphans.insert(upid); - } - orphans -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn collect_same_instance_orphans( - instance_id: &str, - skip_pids: &[u32], -) -> std::collections::HashSet { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut orphans = std::collections::HashSet::new(); - - let Ok(entries) = std::fs::read_dir("/proc") else { - return orphans; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_linux(upid, skip_pids) { - continue; - } - orphans.insert(upid); - } - orphans -} - -#[cfg(not(unix))] -pub(crate) fn collect_same_instance_orphans( - _instance_id: &str, - _skip_pids: &[u32], -) -> std::collections::HashSet { - std::collections::HashSet::new() -} - -/// Binary names for the Buzz desktop/Tauri process. Used by dead-instance -/// detection to confirm the owning desktop is still alive. -const DESKTOP_BINARY_NAMES: &[&str] = &["Buzz", "buzz-desktop", "buzz_desktop"]; - -/// Check if a process name matches a known Buzz desktop binary. -fn is_desktop_binary(name: &str) -> bool { - DESKTOP_BINARY_NAMES.contains(&name) -} - -/// Check whether `buf` contains `id` as a complete identifier — not as a -/// prefix of a longer dotted name. The identifier appears in the Tauri config -/// JSON as `"identifier":"xyz.block.buzz.app.dev"` and in environment entries -/// as `KEY=...app.dev\0`, so a valid match is followed by a non-identifier byte -/// (not `[A-Za-z0-9._-]`) or sits at the end of the buffer. This prevents -/// `xyz.block.buzz.app` from matching inside `xyz.block.buzz.app.dev`. -fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool { - if id.is_empty() { - return false; - } - buf.windows(id.len()).enumerate().any(|(i, w)| { - if w != id { - return false; - } - // Boundary check on the byte immediately after the match: end-of-buffer - // or any byte that can't continue a dotted reverse-DNS identifier. - match buf.get(i + id.len()) { - None => true, - Some(&next) => { - !next.is_ascii_alphanumeric() && next != b'.' && next != b'_' && next != b'-' - } - } - }) -} - -/// Extract the `BUZZ_MANAGED_AGENT` value from a process's environment. -/// Returns `None` if the process doesn't have the marker or can't be read. -#[cfg(target_os = "macos")] -fn extract_buzz_marker_value(pid: u32) -> Option { - let prefix = b"BUZZ_MANAGED_AGENT="; - let buf = sweep::procargs2_buffer(pid)?; - - if buf.len() < std::mem::size_of::() { - return None; - } - let mut n_args: libc::c_int = 0; - unsafe { - std::ptr::copy_nonoverlapping( - buf.as_ptr(), - &mut n_args as *mut libc::c_int as *mut u8, - std::mem::size_of::(), - ); - } - let mut pos = std::mem::size_of::(); - - // Skip exec path. - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - // Skip argc argument strings. - let mut args_remaining = n_args; - while args_remaining > 0 && pos < buf.len() { - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - args_remaining -= 1; - } - // Search environment entries for our marker. - for entry in buf[pos..].split(|&b| b == 0) { - if entry.starts_with(prefix) { - return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); - } - } - None -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn extract_buzz_marker_value(pid: u32) -> Option { - let prefix = b"BUZZ_MANAGED_AGENT="; - let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?; - for entry in data.split(|&b| b == 0) { - if entry.starts_with(prefix) { - return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); - } - } - None -} - -#[cfg(not(unix))] -fn extract_buzz_marker_value(_pid: u32) -> Option { - None -} - -/// Check if a Buzz desktop process is still alive for the given instance ID. -/// Scans all user-owned processes named "Buzz" or "buzz-desktop" and checks -/// whether any has the identifier in its command-line args (KERN_PROCARGS2 buffer -/// includes both argv and environ — the `--config` JSON from `tauri dev` contains -/// the identifier string). -#[cfg(target_os = "macos")] -fn desktop_is_alive_for_instance(instance_id: &str) -> bool { - extern "C" { - fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; - } - - let my_uid = unsafe { libc::getuid() }; - let identifier_bytes = instance_id.as_bytes(); - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return false; - } - - for &pid in &pids { - if pid <= 0 { - continue; - } - // Check binary name — only look at desktop binaries. - let mut name_buf = [0u8; 1024]; - let len = unsafe { - proc_name( - pid, - name_buf.as_mut_ptr() as *mut libc::c_void, - name_buf.len() as u32, - ) - }; - if len <= 0 { - continue; - } - let name = String::from_utf8_lossy(&name_buf[..len as usize]); - if !is_desktop_binary(&name) { - continue; - } - // Verify UID. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - // Check if this desktop process's args/env contain the identifier. - // The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings. - let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else { - continue; - }; - // Boundary-anchored search: the identifier in the config JSON is - // followed by a non-identifier char (typically `"`). A raw substring - // match would let `...app` match inside `...app.dev`. - if buffer_contains_identifier(&args_buf, identifier_bytes) { - return true; - } - } - false -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn desktop_is_alive_for_instance(instance_id: &str) -> bool { - let my_uid = unsafe { libc::getuid() }; - let Ok(entries) = std::fs::read_dir("/proc") else { - return false; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - // Check ownership. - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - // Check binary name via /proc//comm. - let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else { - continue; - }; - if !is_desktop_binary(comm.trim()) { - continue; - } - // Check cmdline for the identifier with boundary anchoring. - let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { - continue; - }; - if buffer_contains_identifier(&cmdline, instance_id.as_bytes()) { - return true; - } - } - false -} - -#[cfg(not(unix))] -fn desktop_is_alive_for_instance(_instance_id: &str) -> bool { - false -} - -/// Reap agent processes belonging to dead Buzz desktop instances. -/// -/// Scans all user processes for `BUZZ_MANAGED_AGENT=*`, groups them by -/// instance ID, and for each foreign instance (≠ `our_instance_id`) checks -/// whether a Buzz desktop binary is still alive for that instance. If not, -/// all agents from that dead instance are reaped. -#[cfg(target_os = "macos")] -pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return; - } - - // Collect (pid, instance_id) for all foreign agent processes. - let mut foreign_agents: HashMap> = HashMap::new(); - - for &pid in &pids { - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - // Verify UID. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - // Extract the instance ID from this agent's env. - let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { - continue; - }; - // Skip agents belonging to our own instance (handled by sweep_system_agent_processes). - if agent_instance_id == our_instance_id { - continue; - } - foreign_agents - .entry(agent_instance_id) - .or_default() - .push(pid); - } - - // For each foreign instance, check if its desktop is still alive. - for (instance_id, agent_pids) in &foreign_agents { - if desktop_is_alive_for_instance(instance_id) { - continue; - } - eprintln!( - "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", - agent_pids.len() - ); - resolve_pgids_and_kill(agent_pids); - } -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut foreign_agents: HashMap> = HashMap::new(); - - let Ok(entries) = std::fs::read_dir("/proc") else { - return; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { - continue; - }; - if agent_instance_id == our_instance_id { - continue; - } - foreign_agents - .entry(agent_instance_id) - .or_default() - .push(pid); - } - - for (instance_id, agent_pids) in &foreign_agents { - if desktop_is_alive_for_instance(instance_id) { - continue; - } - eprintln!( - "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", - agent_pids.len() - ); - resolve_pgids_and_kill(agent_pids); - } -} - -#[cfg(not(unix))] -pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {} +mod instance_reaper; +pub(crate) use instance_reaper::reap_dead_instance_agents; +#[cfg(test)] +use instance_reaper::{buffer_contains_identifier, is_desktop_binary}; // Exact-path harness sweep lives in runtime/sweep.rs (re-exported above). -/// Kill stale agent processes from a previous session whose PID is still alive -/// but not tracked in the current `runtimes` map. Updates the record fields and -/// returns `true` if any records were modified. -pub fn kill_stale_tracked_processes( - records: &mut [ManagedAgentRecord], - runtimes: &HashMap, - instance_id: &str, -) -> bool { - use crate::managed_agents::BackendKind; - - let mut changed = false; - for record in records.iter_mut() { - if record.backend != BackendKind::Local { - continue; - } - let Some(pid) = record.runtime_pid else { - continue; - }; - if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { - if process_belongs_to_us(pid) && process_has_buzz_marker(pid, instance_id) { - let _ = terminate_process(pid); - } - record.runtime_pid = None; - record.last_stopped_at = Some(crate::util::now_iso()); - record.updated_at = crate::util::now_iso(); - changed = true; - } - } - changed -} - -pub fn sync_managed_agent_processes( - records: &mut [ManagedAgentRecord], - runtimes: &mut HashMap, - _instance_id: &str, -) -> (bool, Vec) { - let mut changed = false; - let mut exited = Vec::new(); - - for (key, runtime) in runtimes.iter_mut() { - let status = match runtime.child.try_wait() { - Ok(status) => status, - Err(error) => { - if let Some(record) = records - .iter_mut() - .find(|record| record.pubkey == key.pubkey) - { - record.updated_at = now_iso(); - record.last_error = Some(format!("failed to inspect process state: {error}")); - record.last_error_code = None; - } - changed = true; - exited.push(key.clone()); - continue; - } - }; - - let Some(status) = status else { - continue; - }; - - if let Some(record) = records - .iter_mut() - .find(|record| record.pubkey == key.pubkey) - { - record.updated_at = now_iso(); - record.last_stopped_at = Some(now_iso()); - record.last_exit_code = status.code(); - let log_err = if status.success() { - None - } else { - Some( - super::meaningful_agent_error_from_log(&runtime.log_path).unwrap_or_else( - || super::storage::AgentLogError { - message: format!("harness exited with status {status}"), - code: None, - }, - ), - ) - }; - record.last_error = log_err.as_ref().map(|e| e.message.clone()); - record.last_error_code = log_err.as_ref().and_then(|e| e.code); - } - - changed = true; - exited.push(key.clone()); - } - - let exited_pubkeys: Vec = exited.iter().map(|key| key.pubkey.clone()).collect(); - for key in exited { - runtimes.remove(&key); - } - - // `runtime_pid` is legacy bookkeeping. Pair runtimes and receipts are the - // authoritative lifecycle source; migration cleanup is handled separately. - for record in records.iter_mut() { - if record.runtime_pid.take().is_some() { - record.updated_at = now_iso(); - changed = true; - } - } - - (changed, exited_pubkeys) -} +mod lifecycle; +#[cfg(test)] +use lifecycle::kill_stale_tracked_processes_with; +pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; /// Classify an agent's persona against the live catalog for the Agents-menu /// drift indicator. Returns `(out_of_date, orphaned)`. @@ -1376,6 +135,7 @@ pub fn build_managed_agent_summary( record: &ManagedAgentRecord, runtimes: &HashMap, personas: &[crate::managed_agents::types::AgentDefinition], + global_config: &crate::managed_agents::GlobalAgentConfig, ) -> Result { use crate::managed_agents::BackendKind; @@ -1435,12 +195,37 @@ pub fn build_managed_agent_summary( } }; - // Display contract: show the pinned record snapshot — what the agent - // actually runs — not the live persona. The drift flags below signal when - // the snapshot has fallen behind an edited persona; showing live values - // next to an "out of date" badge would contradict it. let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); + let global_for_summary = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, + personas, + &global_for_summary, + ); + let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg + { + crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => { + let source = cfg.model.source.clone(); + ( + cfg.model.value, + cfg.provider.value, + cfg.system_prompt.value, + Some(source), + ) + } + crate::managed_agents::effective_config::EffectiveConfigResult::OrphanedInstance { + record_pubkey, + missing_persona_id, + } => { + eprintln!( + "orphaned agent instance: pubkey={record_pubkey}, missing_persona_id={missing_persona_id}" + ); + (None, None, None, None) + } + }; + // Restart badge: the running process stamped its effective spawn config // at launch; recompute from current disk state and flag drift. Only the // tracked live pair for THIS workspace can drift — stopped agents spawn @@ -1454,12 +239,15 @@ pub fn build_managed_agent_summary( // stamped at spawn. This catches out-of-band adapter changes (manual // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The // cache is read-only here — no subprocess is spawned. + // + // Global config drives both the restart-drift hash and descriptor env + // layering below — the caller loads it once and passes it in, so + // list-style callers pay one disk read per call rather than one per record. + let needs_restart = pair_key .as_ref() .and_then(|key| runtimes.get(key).map(|runtime| (key, runtime))) .is_some_and(|(key, runtime)| { - let global_for_hash = - crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); let hash_drift = runtime.spawn_config_hash != crate::managed_agents::spawn_hash::spawn_config_hash( @@ -1467,20 +255,44 @@ pub fn build_managed_agent_summary( personas, &teams_for_hash, &key.relay_url, - &global_for_hash, + global_config, ); let availability_drift = super::availability_drift( runtime.adapter_availability.as_ref(), super::adapter_availability_cached(), ); - hash_drift || availability_drift + // An orphan can never be restarted successfully — + // `spawn_agent_child` refuses it before any process side effect — + // so `needs_restart` must never fire for one regardless of hash or + // availability drift. Surfacing "Restart required" here would offer + // an action guaranteed to fail; the UI shows `persona_orphaned` + // instead (see `ManagedAgentSummary::persona_orphaned`). + restart_eligible(persona_orphaned, hash_drift, availability_drift) }); - // Resolve the effective harness the same way, then derive args/mcp from it, - // so the UI reflects the persona's current harness (or an explicit pin). - let effective_command = crate::managed_agents::record_agent_command(record, personas); - let effective_args = normalize_agent_args(&effective_command, record.agent_args.clone()); - let effective_mcp_command = known_acp_runtime(&effective_command) + // Resolve the effective harness via the single typed descriptor — same resolver + // as spawn, so the UI reflects the persona's current harness (or explicit pin). + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + record, + personas, + global_config, + ) + .unwrap_or_else(|e| { + // Dangling harness — surface the missing id so the UI tells the same + // story as spawn (which refuses with a sentence), rather than silently + // showing the default-command fallback as if the agent were healthy. + let cmd = match crate::managed_agents::dangling_harness_id(&e) { + Some(id) => crate::managed_agents::dangling_harness_display(id), + None => crate::managed_agents::record_agent_command(record, personas), + }; + let args = normalize_agent_args(&cmd, record.agent_args.clone()); + crate::managed_agents::readiness::EffectiveHarnessDescriptor { + command: cmd, + args, + env: Default::default(), + } + }); + let effective_mcp_command = known_acp_runtime(&descriptor.command) .and_then(|r| r.mcp_command) .unwrap_or("") .to_string(); @@ -1489,21 +301,23 @@ pub fn build_managed_agent_summary( pubkey: record.pubkey.clone(), name: record.name.clone(), persona_id: record.persona_id.clone(), + runtime: record.runtime.clone(), team_id: record.team_id.clone(), relay_url: record.relay_url.clone(), acp_command: record.acp_command.clone(), - agent_command: effective_command, + agent_command: descriptor.command, agent_command_override: record.agent_command_override.clone(), - agent_args: effective_args, + agent_args: descriptor.args, mcp_command: effective_mcp_command, turn_timeout_seconds: record.turn_timeout_seconds, idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, - system_prompt: record.system_prompt.clone(), + system_prompt: effective_prompt, avatar_url: record.avatar_url.clone(), - model: record.model.clone(), - provider: record.provider.clone(), + model: effective_model, + model_source, + provider: effective_provider, persona_out_of_date, persona_orphaned, needs_restart, @@ -1527,6 +341,19 @@ pub fn build_managed_agent_summary( }) } +/// Pure predicate: should the "Restart required" badge fire? +/// +/// An orphaned linked instance (its persona/definition no longer exists) +/// can never be restarted successfully — `spawn_agent_child` refuses to +/// spawn it before any process side effect. Surfacing "Restart required" +/// for one would offer an action guaranteed to fail, so this always +/// returns `false` for an orphan regardless of drift. Extracted for unit +/// testing without `AppHandle`/global state, following the +/// `availability_drift` pattern in `discovery.rs`. +fn restart_eligible(persona_orphaned: bool, hash_drift: bool, availability_drift: bool) -> bool { + !persona_orphaned && (hash_drift || availability_drift) +} + pub fn find_managed_agent_mut<'a>( records: &'a mut [ManagedAgentRecord], pubkey: &str, @@ -1635,6 +462,51 @@ pub fn spawn_agent_child( return Err(error); } let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + // Resolve the effective harness (agent command) from the linked persona, so + // persona harness edits propagate on the next spawn; an explicit per-agent + // override wins. `agent_args` and `mcp_command` are pure derivations of the + // command, so we recompute them from the effective value rather than the + // frozen record snapshot. Mirrors the model resolution below. + let personas = super::load_personas(app).unwrap_or_default(); + let teams = super::load_teams(app).unwrap_or_default(); + // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) + // and for the env-var merge at spawn time. + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + + // Resolve model/provider/prompt ONCE, here, at the shared spawn boundary — + // the single source both the env writes below and `spawn_config_hash` + // read from. Previously prompt was read from the record's own (possibly + // stale, Phase-A-snapshot) bytes while model/provider were resolved live + // from `personas`; a definition edit landing between a caller's snapshot + // apply and this spawn could hand a fresh model/provider to a stale + // prompt. This also folds in orphan refusal via `require_resolved`: every + // caller (interactive start, launch restore, `start_managed_agent_process`) + // inherits it — no caller can bypass this by reaching `spawn_agent_child` + // directly. Checked before any side effect (log marker, log file, process + // spawn) so a refused spawn leaves no trace. + let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, &personas, &global, + ) + .require_resolved()?; + + // Single typed resolver: validates runtime id (dangling harness → Err), resolves + // command, args (instance wins over definition default), and the full env layer stack. + // This is the sole path for harness-definition lookup — spawn, hash, summary, and + // model probes all consume this descriptor rather than assembling values inline. + // Like the orphan refusal above, this runs before any side effect so a refused + // spawn leaves no trace. + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) + .map_err(|e| { + format!( + "cannot spawn agent {}: {}", + record.pubkey, + crate::managed_agents::user_facing_harness_error(&e) + ) + })?; + let effective_command = &descriptor.command; + let agent_args = &descriptor.args; + let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, @@ -1650,21 +522,9 @@ pub fn spawn_agent_child( let stderr = stdout .try_clone() .map_err(|error| format!("failed to clone log handle: {error}"))?; - // Resolve the effective harness (agent command) from the linked persona, so - // persona harness edits propagate on the next spawn; an explicit per-agent - // override wins. `agent_args` and `mcp_command` are pure derivations of the - // command, so we recompute them from the effective value rather than the - // frozen record snapshot. Mirrors the model resolution below. - let personas = super::load_personas(app).unwrap_or_default(); - let teams = super::load_teams(app).unwrap_or_default(); - // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) - // and for the env-var merge at spawn time. - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let effective_command = super::record_agent_command(record, &personas); - let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - let effective_mcp_command = known_acp_runtime(&effective_command) + let effective_mcp_command = known_acp_runtime(effective_command) .and_then(|r| r.mcp_command) .unwrap_or(""); let resolved_mcp_command: Option = if effective_mcp_command.is_empty() { @@ -1681,7 +541,7 @@ pub fn spawn_agent_child( } }; // Resolve agent command to a full path (DMG launches have minimal PATH). - let resolved_agent_command = resolve_command(&effective_command) + let resolved_agent_command = resolve_command(effective_command) .map(|p| p.display().to_string()) .unwrap_or_else(|| effective_command.clone()); @@ -1732,7 +592,7 @@ pub fn spawn_agent_child( } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". - let runtime_meta = known_acp_runtime(&effective_command); + let runtime_meta = known_acp_runtime(effective_command); if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } @@ -1758,11 +618,16 @@ pub fn spawn_agent_child( // stuck agents for auto-restart. let spawned_setup_mode; { - use crate::managed_agents::{ - agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, - }; + use crate::managed_agents::readiness::EffectiveAgentEnv; + use crate::managed_agents::{agent_readiness, AgentReadiness, Requirement}; - let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global); + // Construct EffectiveAgentEnv from the descriptor computed above — no second + // resolver call; the descriptor's env is already the fully layered result. + let effective = EffectiveAgentEnv { + env: descriptor.env.clone(), + config_file_path: runtime_meta.and_then(|r| r.config_file_path), + effective_command: descriptor.command.clone(), + }; // Compute the optional payload before touching the command. let setup_payload_json = if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { @@ -1800,6 +665,10 @@ pub fn spawn_agent_child( Requirement::GitBash => serde_json::json!({ "surface": "git_bash", }), + Requirement::MissingBinary { command } => serde_json::json!({ + "surface": "missing_binary", + "command": command, + }), }) .collect(); let payload = serde_json::json!({ @@ -1874,37 +743,49 @@ pub fn spawn_agent_child( command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); } - // System prompt via the shared spawn-effective filter — the SAME function the - // config hash digests, so env write and badge cannot disagree (see - // `effective_spawn_prompt` for the Some("")/None collapse). Model and provider - // use the shared - // resolver: agent → persona → global → None, so a global-default-only agent - // spawns with the correct provider/model env. - let effective_prompt = super::spawn_hash::effective_spawn_prompt(record); - let (effective_model, effective_provider) = - crate::managed_agents::resolve_effective_model_provider(record, &personas, &global); + // Prompt, model, and provider all come from the single `effective_cfg` + // resolved at the top of this function — the SAME resolve `spawn_config_hash` + // performs below, so env write and restart badge cannot disagree. Linked + // instances never consult the record's own model/provider/prompt bytes; + // definition-less instances fall back to their own fields, then global. + // + // Derive the mesh decision BEFORE moving fields out — `relay_mesh_model_id` + // is the single authoritative gate; the mesh-llm block below MUST use it + // rather than re-deriving from `effective_provider` to keep preflight and + // spawn semantics in lock-step (see `EffectiveAgentConfig::relay_mesh_model_id`). + #[cfg(feature = "mesh-llm")] + let mesh_model_id = effective_cfg.relay_mesh_model_id(); + let effective_prompt = effective_cfg.system_prompt.value; + let effective_model = effective_cfg.model.value; + let effective_provider = effective_cfg.provider.value; if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = effective_model { + if let Some(model) = effective_model.as_deref() { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); } - // Baked-in provider defaults for internal builds (buzz-releases sets - // BUZZ_BUILD_BUZZ_AGENT_* at compile time; OSS builds bake nothing). - // Written FIRST so that record/persona metadata env vars below override them. + // 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( meta.model_env_var, meta.provider_env_var, meta.provider_locked, - effective_model, - effective_provider, + effective_model.as_deref(), + effective_provider.as_deref(), ) { command.env(key, value); } @@ -1954,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"), @@ -1967,38 +849,32 @@ pub fn spawn_agent_child( ); } - // ── User env vars: live persona env under agent overrides ────────── + // ── User env vars: definition floor + global + live persona + agent overrides ── // - // The record's `env_vars` holds agent-level overrides only. The linked - // persona's env is read live and merged underneath (agent wins on - // collision), so persona credential edits reach the agent on the next - // spawn — same refresh semantics as prompt/model/provider above and the - // provider deploy path. Global env vars are the floor layer below persona. - // `merged_user_env` also applies the reserved-key / malformed-key / NUL - // filtering. Precedence: baked floor < Buzz-set env above < GLOBAL < - // PERSONA < per-agent. - // - // These writes go LAST so user-provided values win over every Buzz-set env - // above — EXCEPT reserved keys (BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, - // BUZZ_AUTH_TAG, BUZZ_API_TOKEN, BUZZ_ACP_PRIVATE_KEY, BUZZ_ACP_API_TOKEN), - // which `merged_user_env` strips. Those carry Buzz's identity and must - // never be GUI-overridable. - // global < live persona < agent (last-wins on collision at each layer). - let persona_over_global = super::env_vars::merged_user_env( - &global.env_vars, - &super::env_vars::live_persona_env(&personas, record.persona_id.as_deref()), - ); - for (key, value) in super::env_vars::merged_user_env(&persona_over_global, &record.env_vars) { + // `descriptor.env` is the fully-layered result from `resolve_effective_harness_descriptor`: + // baked floor → runtime metadata → definition env (harness author defaults) → + // global → live persona → per-agent, with reserved-key and malformed-key filtering + // applied. Writing it last lets user-provided values win over every Buzz-set env + // written above — reserved keys were already stripped from descriptor.env so they + // cannot clobber BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, etc. + for (key, value) in &descriptor.env { command.env(key, value); } configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible // transport at spawn time and scrub any unrelated ambient OpenAI key. + // Gate on `mesh_model_id` (derived from `effective_cfg.relay_mesh_model_id()` + // above) — not on `effective_provider` directly — so the mesh gate here + // uses the same trim semantics as the preflight callers. #[cfg(feature = "mesh-llm")] - if effective_provider == Some(super::RELAY_MESH_PROVIDER_ID) { + if let Some(ref mesh_model_id) = mesh_model_id { let mut mesh_env = std::collections::BTreeMap::new(); - super::apply_relay_mesh_env(&mut mesh_env, effective_provider, effective_model); + super::apply_relay_mesh_env( + &mut mesh_env, + Some(super::RELAY_MESH_PROVIDER_ID), + Some(mesh_model_id.as_str()), + ); command.env_remove("OPENAI_API_KEY"); for (key, value) in mesh_env { command.env(key, value); @@ -2152,61 +1028,5 @@ pub fn start_managed_agent_process( Ok(()) } -/// Returns the (key, value) env var pairs that should be forwarded to the -/// agent process for model and provider selection. -/// -/// Model injection is unconditional — even agents that support ACP model -/// switching need the initial bootstrap value. Provider injection is skipped -/// when `provider_locked` is true (e.g. Claude runtimes that only work with -/// Anthropic). -pub(crate) fn runtime_metadata_env_vars<'a>( - model_env_var: Option<&'a str>, - provider_env_var: Option<&'a str>, - provider_locked: bool, - effective_model: Option<&'a str>, - effective_provider: Option<&'a str>, -) -> Vec<(&'a str, &'a str)> { - let mut vars = Vec::new(); - if let (Some(env_key), Some(model)) = (model_env_var, effective_model) { - vars.push((env_key, model)); - } - if !provider_locked { - if let (Some(env_key), Some(provider)) = (provider_env_var, effective_provider) { - vars.push((env_key, provider)); - } - } - vars -} - -/// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. -/// -/// Given a persona_id, finds the persona in the list and returns its system_prompt, -/// model, and provider as the authoritative values. When the persona leaves `model` -/// or `provider` blank (None or whitespace-only), falls back to the record's own -/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` -/// so the display surface matches spawn behavior. Falls back to the record's own -/// prompt/model/provider when no persona is linked or found. -/// -/// Used by `agent_config.rs` to inject persona defaults into the config surface -/// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to -/// PersonaDefault for fields the record did not independently set. -pub(crate) fn resolve_effective_prompt_model_provider( - persona_id: Option<&str>, - personas: &[crate::managed_agents::types::AgentDefinition], - record_prompt: Option, - record_model: Option, - record_provider: Option, -) -> (Option, Option, Option) { - let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; - match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { - Some(p) => ( - Some(p.system_prompt.clone()), - fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model - fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider - ), - None => (record_prompt, record_model, record_provider), - } -} - #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs b/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs new file mode 100644 index 0000000000..dec8565330 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs @@ -0,0 +1,359 @@ +use super::*; + +/// Binary names for the Buzz desktop/Tauri process. Used by dead-instance +/// detection to confirm the owning desktop is still alive. +const DESKTOP_BINARY_NAMES: &[&str] = &[ + "Buzz", + "buzz-desktop", + "buzz_desktop", + // Linux limits /proc//comm to 15 visible bytes, truncating the + // AppImage shim's real executable name, `buzz-desktop.bin`. + "buzz-desktop.bi", +]; + +/// Check if a process name matches a known Buzz desktop binary. +pub(super) fn is_desktop_binary(name: &str) -> bool { + DESKTOP_BINARY_NAMES.contains(&name) +} + +/// Check whether `buf` contains `id` as a complete identifier — not as a +/// prefix of a longer dotted name. The identifier appears in the Tauri config +/// JSON as `"identifier":"xyz.block.buzz.app.dev"` and in environment entries +/// as `KEY=...app.dev\0`, so a valid match is followed by a non-identifier byte +/// (not `[A-Za-z0-9._-]`) or sits at the end of the buffer. This prevents +/// `xyz.block.buzz.app` from matching inside `xyz.block.buzz.app.dev`. +pub(super) fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool { + if id.is_empty() { + return false; + } + buf.windows(id.len()).enumerate().any(|(i, w)| { + if w != id { + return false; + } + // Boundary check on the byte immediately after the match: end-of-buffer + // or any byte that can't continue a dotted reverse-DNS identifier. + match buf.get(i + id.len()) { + None => true, + Some(&next) => { + !next.is_ascii_alphanumeric() && next != b'.' && next != b'_' && next != b'-' + } + } + }) +} + +/// Extract the `BUZZ_MANAGED_AGENT` value from a process's environment. +/// Returns `None` if the process doesn't have the marker or can't be read. +#[cfg(target_os = "macos")] +fn extract_buzz_marker_value(pid: u32) -> Option { + let prefix = b"BUZZ_MANAGED_AGENT="; + let buf = sweep::procargs2_buffer(pid)?; + + if buf.len() < std::mem::size_of::() { + return None; + } + let mut n_args: libc::c_int = 0; + unsafe { + std::ptr::copy_nonoverlapping( + buf.as_ptr(), + &mut n_args as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + } + let mut pos = std::mem::size_of::(); + + // Skip exec path. + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + // Skip argc argument strings. + let mut args_remaining = n_args; + while args_remaining > 0 && pos < buf.len() { + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + args_remaining -= 1; + } + // Search environment entries for our marker. + for entry in buf[pos..].split(|&b| b == 0) { + if entry.starts_with(prefix) { + return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); + } + } + None +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extract_buzz_marker_value(pid: u32) -> Option { + let prefix = b"BUZZ_MANAGED_AGENT="; + let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?; + for entry in data.split(|&b| b == 0) { + if entry.starts_with(prefix) { + return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); + } + } + None +} + +#[cfg(not(unix))] +fn extract_buzz_marker_value(_pid: u32) -> Option { + None +} + +/// Check if a Buzz desktop process is still alive for the given instance ID. +/// Scans all user-owned processes named "Buzz" or "buzz-desktop" and checks +/// whether any has the identifier in its command-line args (KERN_PROCARGS2 buffer +/// includes both argv and environ — the `--config` JSON from `tauri dev` contains +/// the identifier string). +#[cfg(target_os = "macos")] +fn desktop_is_alive_for_instance(instance_id: &str) -> bool { + extern "C" { + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; + } + + let my_uid = unsafe { libc::getuid() }; + let identifier_bytes = instance_id.as_bytes(); + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return false; + } + + for &pid in &pids { + if pid <= 0 { + continue; + } + // Check binary name — only look at desktop binaries. + let mut name_buf = [0u8; 1024]; + let len = unsafe { + proc_name( + pid, + name_buf.as_mut_ptr() as *mut libc::c_void, + name_buf.len() as u32, + ) + }; + if len <= 0 { + continue; + } + let name = String::from_utf8_lossy(&name_buf[..len as usize]); + if !is_desktop_binary(&name) { + continue; + } + // Verify UID. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Check if this desktop process's args/env contain the identifier. + // The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings. + let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else { + continue; + }; + // Boundary-anchored search: the identifier in the config JSON is + // followed by a non-identifier char (typically `"`). A raw substring + // match would let `...app` match inside `...app.dev`. + if buffer_contains_identifier(&args_buf, identifier_bytes) { + return true; + } + } + false +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn desktop_is_alive_for_instance(instance_id: &str) -> bool { + let my_uid = unsafe { libc::getuid() }; + let Ok(entries) = std::fs::read_dir("/proc") else { + return false; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + // Check ownership. + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + // Check binary name via /proc//comm. + let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else { + continue; + }; + if !is_desktop_binary(comm.trim()) { + continue; + } + // Check cmdline for the identifier with boundary anchoring. + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { + continue; + }; + if buffer_contains_identifier(&cmdline, instance_id.as_bytes()) { + return true; + } + } + false +} + +#[cfg(not(unix))] +fn desktop_is_alive_for_instance(_instance_id: &str) -> bool { + false +} + +/// Reap agent processes belonging to dead Buzz desktop instances. +/// +/// Scans all user processes for `BUZZ_MANAGED_AGENT=*`, groups them by +/// instance ID, and for each foreign instance (≠ `our_instance_id`) checks +/// whether a Buzz desktop binary is still alive for that instance. If not, +/// all agents from that dead instance are reaped. +#[cfg(target_os = "macos")] +pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; + } + + // Collect (pid, instance_id) for all foreign agent processes. + let mut foreign_agents: HashMap> = HashMap::new(); + + for &pid in &pids { + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + // Verify UID and PPID via proc_pidinfo before the more expensive env scan. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Extract the instance ID from this agent's env. + // Do NOT name-gate via process_belongs_to_us — custom harnesses use + // arbitrary binary names and BUZZ_MANAGED_AGENT is the authoritative + // ownership proof. + let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { + continue; + }; + // Skip agents belonging to our own instance (handled by sweep_system_agent_processes). + if agent_instance_id == our_instance_id { + continue; + } + foreign_agents + .entry(agent_instance_id) + .or_default() + .push(pid); + } + + // For each foreign instance, check if its desktop is still alive. + for (instance_id, agent_pids) in &foreign_agents { + if desktop_is_alive_for_instance(instance_id) { + continue; + } + eprintln!( + "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", + agent_pids.len() + ); + resolve_pgids_and_kill(agent_pids); + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut foreign_agents: HashMap> = HashMap::new(); + + let Ok(entries) = std::fs::read_dir("/proc") else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + // Do NOT name-gate via process_belongs_to_us — custom harnesses use + // arbitrary binary names and BUZZ_MANAGED_AGENT is the authoritative + // ownership proof. + let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { + continue; + }; + if agent_instance_id == our_instance_id { + continue; + } + foreign_agents + .entry(agent_instance_id) + .or_default() + .push(pid); + } + + for (instance_id, agent_pids) in &foreign_agents { + if desktop_is_alive_for_instance(instance_id) { + continue; + } + eprintln!( + "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", + agent_pids.len() + ); + resolve_pgids_and_kill(agent_pids); + } +} + +#[cfg(not(unix))] +pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {} diff --git a/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs b/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs new file mode 100644 index 0000000000..6363b15d2a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs @@ -0,0 +1,125 @@ +use super::*; + +/// Kill stale agent processes from a previous session whose PID is still alive +/// but not tracked in the current `runtimes` map. Updates the record fields and +/// returns `true` if any records were modified. +pub fn kill_stale_tracked_processes( + records: &mut [ManagedAgentRecord], + runtimes: &HashMap, + instance_id: &str, +) -> bool { + kill_stale_tracked_processes_with( + records, + runtimes, + |pid| process_has_buzz_marker(pid, instance_id), + terminate_process, + ) +} + +/// Injectable version of `kill_stale_tracked_processes` for testing. +/// `has_marker(pid)` returns true when the process carries this instance's +/// `BUZZ_MANAGED_AGENT` marker; `kill(pid)` performs the termination. +pub(crate) fn kill_stale_tracked_processes_with( + records: &mut [ManagedAgentRecord], + runtimes: &HashMap, + has_marker: impl Fn(u32) -> bool, + mut kill: impl FnMut(u32) -> Result<(), String>, +) -> bool { + use crate::managed_agents::BackendKind; + + let mut changed = false; + for record in records.iter_mut() { + if record.backend != BackendKind::Local { + continue; + } + let Some(pid) = record.runtime_pid else { + continue; + }; + if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { + // Name-gate is omitted intentionally: custom harnesses use arbitrary + // binary names not in KNOWN_AGENT_BINARIES. BUZZ_MANAGED_AGENT is the + // authoritative ownership proof; terminate only if it matches. + if has_marker(pid) { + let _ = kill(pid); + } + record.runtime_pid = None; + record.last_stopped_at = Some(crate::util::now_iso()); + record.updated_at = crate::util::now_iso(); + changed = true; + } + } + changed +} + +pub fn sync_managed_agent_processes( + records: &mut [ManagedAgentRecord], + runtimes: &mut HashMap, + _instance_id: &str, +) -> (bool, Vec) { + let mut changed = false; + let mut exited = Vec::new(); + + for (key, runtime) in runtimes.iter_mut() { + let status = match runtime.child.try_wait() { + Ok(status) => status, + Err(error) => { + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { + record.updated_at = now_iso(); + record.last_error = Some(format!("failed to inspect process state: {error}")); + record.last_error_code = None; + } + changed = true; + exited.push(key.clone()); + continue; + } + }; + + let Some(status) = status else { + continue; + }; + + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { + record.updated_at = now_iso(); + record.last_stopped_at = Some(now_iso()); + record.last_exit_code = status.code(); + let log_err = if status.success() { + None + } else { + Some( + super::super::meaningful_agent_error_from_log(&runtime.log_path) + .unwrap_or_else(|| super::super::storage::AgentLogError { + message: format!("harness exited with status {status}"), + code: None, + }), + ) + }; + record.last_error = log_err.as_ref().map(|e| e.message.clone()); + record.last_error_code = log_err.as_ref().and_then(|e| e.code); + } + + changed = true; + exited.push(key.clone()); + } + + let exited_pubkeys: Vec = exited.iter().map(|key| key.pubkey.clone()).collect(); + for key in exited { + runtimes.remove(&key); + } + + // `runtime_pid` is legacy bookkeeping. Pair runtimes and receipts are the + // authoritative lifecycle source; migration cleanup is handled separately. + for record in records.iter_mut() { + if record.runtime_pid.take().is_some() { + record.updated_at = now_iso(); + changed = true; + } + } + + (changed, exited_pubkeys) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs new file mode 100644 index 0000000000..288ce06b0a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -0,0 +1,150 @@ +/// Returns the (key, value) env var pairs that should be forwarded to the +/// agent process for model and provider selection. +/// +/// Model injection is unconditional — even agents that support ACP model +/// switching need the initial bootstrap value. Provider injection is skipped +/// when `provider_locked` is true (e.g. Claude runtimes that only work with +/// Anthropic). +pub(crate) fn runtime_metadata_env_vars<'a>( + model_env_var: Option<&'a str>, + provider_env_var: Option<&'a str>, + provider_locked: bool, + effective_model: Option<&'a str>, + effective_provider: Option<&'a str>, +) -> Vec<(&'a str, &'a str)> { + let mut vars = Vec::new(); + if let (Some(env_key), Some(model)) = (model_env_var, effective_model) { + vars.push((env_key, model)); + } + if !provider_locked { + if let (Some(env_key), Some(provider)) = (provider_env_var, effective_provider) { + vars.push((env_key, provider)); + } + } + 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. +/// +/// Used by `agent_config.rs` to inject persona defaults into the config surface +/// before running the reader. +pub(crate) fn resolve_effective_prompt_model_provider( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::AgentDefinition], + record_prompt: Option, + record_model: Option, + record_provider: Option, +) -> (Option, Option, Option) { + match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { + Some(p) => { + fn non_blank(v: Option<&str>) -> Option { + v.filter(|s| !s.trim().is_empty()).map(str::to_owned) + } + let prompt = non_blank(Some(&p.system_prompt)); + let model = non_blank(p.model.as_deref()); + let provider = non_blank(p.provider.as_deref()); + (prompt, model, provider) + } + None => (record_prompt, record_model, record_provider), + } +} + +#[cfg(test)] +mod tests { + use super::resolve_session_title; + + #[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/orphan_sweep.rs b/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs new file mode 100644 index 0000000000..336bbd55ce --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs @@ -0,0 +1,387 @@ +use super::*; + +/// Kill orphaned agent processes using PID file receipts. Reads all files from +/// `agent-pids/`, verifies each PID still belongs to a known agent binary, +/// then resolves each candidate's actual PGID and signals the process group. +/// Deletes the PID file after killing. +/// +/// `skip_pids` are PIDs already handled by the tracked-agent path. +#[cfg(unix)] +pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { + let legacy_entries = super::super::read_all_agent_pid_files(app); + let instance_id = current_instance_id(app); + let receipt_entries: Vec<_> = super::super::read_all_agent_runtime_receipts(app) + .into_iter() + .filter_map(|(path, receipt)| { + if valid_agent_runtime_receipt(&path, &receipt, &instance_id) { + Some((path, receipt)) + } else { + super::super::remove_agent_runtime_receipt_path(&path); + None + } + }) + .collect(); + // Collect live orphans AND dead-leader groups into a single kill batch. + // Dead leaders: PGID may have been recycled, but the window is narrow + // (PID files are from this session) and the cost of missing surviving + // group members outweighs the recycling risk. + let targets: Vec = legacy_entries + .iter() + .map(|(_, pid)| *pid) + .chain(receipt_entries.iter().map(|(_, receipt)| receipt.pid)) + .filter(|pid| { + if skip_pids.contains(pid) { + return false; + } + // Receipt/PID-file entries were written by this instance at spawn + // time — they are Buzz-owned by construction; no name gate needed. + // Kill live processes; dead ones fall through to receipt cleanup. + (process_is_running(*pid) && process_has_buzz_marker(*pid, &instance_id)) + || !process_is_running(*pid) + }) + .map(|pid| pid as i32) + .collect(); + + if !targets.is_empty() { + resolve_pgids_and_kill(&targets); + } + + // Clean up PID files for processes we just killed or that are already gone. + for (pubkey, pid) in &legacy_entries { + if skip_pids.contains(pid) { + continue; + } + if !process_is_running(*pid) || !process_has_buzz_marker(*pid, &instance_id) { + super::super::remove_agent_pid_file(app, pubkey); + } + } + for (_, receipt) in &receipt_entries { + if skip_pids.contains(&receipt.pid) { + continue; + } + if !process_is_running(receipt.pid) || !process_has_buzz_marker(receipt.pid, &instance_id) { + super::super::remove_agent_runtime_receipt(app, &receipt.key); + } + } +} + +#[cfg(not(unix))] +pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]) { + let _ = app; +} + +// ── macOS process-info FFI (shared by all sweep/reap functions) ────────── +// +// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`). +// All callers in this file reach it through `sweep::collect_all_pids()`. +// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that +// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without +// redefining the struct layout in two places. + +#[cfg(target_os = "macos")] +extern "C" { + pub(super) fn proc_pidinfo( + pid: libc::c_int, + flavor: libc::c_int, + arg: u64, + buffer: *mut libc::c_void, + buffersize: libc::c_int, + ) -> libc::c_int; +} + +/// Subset of `struct proc_bsdinfo` from ``. Layout verified +/// against the macOS SDK — total size 136 bytes. +#[cfg(target_os = "macos")] +#[repr(C)] +pub(super) struct BSDInfo { + _flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus + pub(super) pbi_pid: u32, // offset 12 + pub(super) pbi_ppid: u32, // offset 16 + pub(super) pbi_uid: u32, // offset 20 + _rest: [u8; 112], +} + +#[cfg(target_os = "macos")] +const _: () = assert!(std::mem::size_of::() == 136); + +#[cfg(target_os = "macos")] +pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3; + +// ── Sweep ownership rule ────────────────────────────────────────────────────── +// +// The `BUZZ_MANAGED_AGENT` env marker is the SOLE authoritative ownership +// proof for sweep/receipt decisions. Do NOT name-gate via +// `process_belongs_to_us` here — custom harnesses use arbitrary binary names +// and a name-gated predicate would silently leak their orphans (the old Linux +// AND-gate bug). `process_belongs_to_us` remains in use only as a cheap +// pre-check on paths that already know the binary (see runtime/stop.rs). +// On Windows no `/proc`-based sweep runs, so `process_has_buzz_marker` +// always returns `false`. + +/// Enumerate all processes on the system owned by the current user and kill any +/// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker +/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped +/// PID-file-based cleanup (e.g. agent workers spawned with their own process +/// group whose parent harness already exited and had its PID file removed), +/// while leaving another live Buzz instance's agents untouched. +#[cfg(target_os = "macos")] +pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; + } + let my_pid = std::process::id() as i32; + let mut orphans: Vec = Vec::new(); + + for &pid in &pids { + if pid <= 0 { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) || pid == my_pid { + continue; + } + // Verify UID and PPID via proc_pidinfo before the more expensive env scan. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Custom harnesses don't match KNOWN_AGENT_BINARIES by name; the + // BUZZ_MANAGED_AGENT env marker is the authoritative ownership proof. + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { + continue; + } + orphans.push(pid); + } + + if !orphans.is_empty() { + eprintln!( + "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", + orphans.len() + ); + resolve_pgids_and_kill(&orphans); + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let mut orphans: Vec = Vec::new(); + let my_pid = std::process::id() as i32; + + let Ok(entries) = std::fs::read_dir("/proc") else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + // Check ownership via /proc/ metadata. + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + // Same ownership rule as macOS: the marker is the authoritative gate. + // Fixes custom-harness orphan cleanup on Linux. + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; + } + orphans.push(pid); + } + + if !orphans.is_empty() { + eprintln!( + "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", + orphans.len() + ); + resolve_pgids_and_kill(&orphans); + } +} + +#[cfg(not(unix))] +pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {} + +/// Periodic-sweep variant with two-tick grace: only reaps same-instance orphans +/// that were also seen orphaned on the previous tick. This prevents killing a +/// legitimately-starting agent that spawned between the skip-list snapshot and +/// the process scan. Returns the current orphan set for use as `prev_orphans` +/// on the next tick. +#[cfg(unix)] +pub(crate) fn sweep_system_agent_processes_with_grace( + instance_id: &str, + skip_pids: &[u32], + prev_orphans: &std::collections::HashSet, +) -> std::collections::HashSet { + let current = collect_same_instance_orphans(instance_id, skip_pids); + // Only reap PIDs seen orphaned on two consecutive ticks. + let confirmed: Vec = current + .iter() + .filter(|pid| prev_orphans.contains(pid)) + .map(|&pid| pid as i32) + .collect(); + if !confirmed.is_empty() { + eprintln!( + "buzz-desktop: periodic sweep confirmed {} orphaned agent process(es), cleaning up", + confirmed.len() + ); + resolve_pgids_and_kill(&confirmed); + } + current +} + +#[cfg(not(unix))] +pub(crate) fn sweep_system_agent_processes_with_grace( + _instance_id: &str, + _skip_pids: &[u32], + _prev_orphans: &std::collections::HashSet, +) -> std::collections::HashSet { + std::collections::HashSet::new() +} + +/// Collect PIDs of same-instance agent processes that appear orphaned (not in +/// `skip_pids`). Returns the set for use in two-tick grace logic — does NOT +/// kill anything. +#[cfg(target_os = "macos")] +pub(crate) fn collect_same_instance_orphans( + instance_id: &str, + skip_pids: &[u32], +) -> std::collections::HashSet { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut orphans = std::collections::HashSet::new(); + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return orphans; + } + + for &pid in &pids { + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Custom harnesses don't match KNOWN_AGENT_BINARIES by name; the + // BUZZ_MANAGED_AGENT env marker is the authoritative ownership proof. + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { + continue; + } + orphans.insert(upid); + } + orphans +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn collect_same_instance_orphans( + instance_id: &str, + skip_pids: &[u32], +) -> std::collections::HashSet { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut orphans = std::collections::HashSet::new(); + + let Ok(entries) = std::fs::read_dir("/proc") else { + return orphans; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + // Same ownership rule as macOS: the marker is the authoritative gate. + // Fixes custom-harness orphan cleanup on Linux. + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; + } + orphans.insert(upid); + } + orphans +} + +#[cfg(not(unix))] +pub(crate) fn collect_same_instance_orphans( + _instance_id: &str, + _skip_pids: &[u32], +) -> std::collections::HashSet { + std::collections::HashSet::new() +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index cf6950f577..efec0c903e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -30,30 +30,25 @@ pub(crate) fn should_skip_claude_executable(path: &std::path::Path, is_windows: /// Decide whether the inherited process PATH should be appended to the /// composed PATH. /// -/// On Windows, `login_shell_path()` always returns `None` because Git Bash -/// returns POSIX colon-delimited paths that poison native children. -/// `Command::env("PATH", …)` replaces rather than extends, so without the -/// inherited PATH every child loses node/npm/git. -/// -/// This pure function takes an explicit `is_windows` flag so it can be -/// unit-tested cross-host (macOS CI can pass `true` to exercise the Windows -/// policy without needing the `cfg!(windows)` target). +/// `Command::env("PATH", …)` replaces rather than extends, so a child whose +/// composed PATH carries no native entries loses every system binary. On +/// Windows that is the steady state — `login_shell_path()` always returns +/// `None` because Git Bash returns POSIX colon-delimited paths that poison +/// native children. On Unix it is the failure mode: a login shell that exits +/// non-zero or prints nothing also yields `None`, and the child is then left +/// with only Buzz's managed Node dirs — no `curl`, `sh`, or `tar`, which +/// silently breaks every `curl … | bash` install. The inherited PATH is the +/// floor under both cases, appended last so managed dirs keep precedence. /// /// Rules: -/// - Only append when `is_windows` — on Unix the login-shell PATH always covers -/// the needed runtimes. /// - Suppress when `had_shell_path` is `true` — if a login-shell PATH was /// supplied it already carries the user's native entries; appending the /// process PATH would double them. /// - Suppress when `has_local_context` is `false` — callers that pass no home /// or exe-parent context must not receive a PATH manufactured from ambient /// process state alone. -pub(crate) fn should_use_inherited( - had_shell_path: bool, - has_local_context: bool, - is_windows: bool, -) -> bool { - is_windows && !had_shell_path && has_local_context +pub(crate) fn should_use_inherited(had_shell_path: bool, has_local_context: bool) -> bool { + !had_shell_path && has_local_context } /// Pure PATH composition kernel shared by the install shell and the runtime/probe paths. @@ -91,10 +86,14 @@ pub(crate) fn compose_path_entries( /// 4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) /// 5. exe parent dir — DMG sidecars under `Contents/MacOS/` /// 6. user's login-shell `PATH` — runtimes like node/python from other managers -/// 7. Windows only: the current process `PATH` (appended when no login-shell -/// PATH exists, because callers use `Command::env("PATH", …)` which -/// *replaces* the child's PATH — without this, the child loses node/npm/git -/// and every npm `.cmd` shim fails with `'node' is not recognized`) +/// 7. the current process `PATH` — appended on every platform when no +/// login-shell PATH exists, because callers use `Command::env("PATH", …)` +/// which *replaces* the child's PATH. This is the steady state on Windows, +/// where `login_shell_path()` always returns `None` and without it the +/// child loses node/npm/git and every npm `.cmd` shim fails with +/// `'node' is not recognized`; on Unix it is the login-shell-probe failure +/// fallback, which keeps `curl`/`sh`/`tar` reachable. See +/// [`should_use_inherited`] for the suppression rules. /// /// `shell_path` is the raw colon-delimited string from a login shell, so it is /// split into individual entries before joining. Pushing it as a single segment @@ -145,7 +144,7 @@ pub(in crate::managed_agents) fn build_augmented_path( let inherited: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - let use_inherited = should_use_inherited(had_shell_path, has_local_context, cfg!(windows)); + let use_inherited = should_use_inherited(had_shell_path, has_local_context); let parts = compose_path_entries(managed, login, inherited, use_inherited); if parts.is_empty() { @@ -223,29 +222,86 @@ mod tests { #[cfg(unix)] #[test] fn nvm_bin_none_does_not_add_segment() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + // With no shell_path the inherited process PATH is appended last, so + // pin it to a sentinel to keep the assertion deterministic. + std::env::set_var("PATH", "/sentinel/inherited"); + let result = build_augmented_path( Some(PathBuf::from("/home/user")), Some(PathBuf::from("/usr/local/bin")), None, None, ); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + let result = result.expect("path"); assert!(result.starts_with("/home/user/.local/bin:"), "{result}"); - assert!(result.ends_with(":/usr/local/bin"), "{result}"); + assert!(!result.contains(".nvm"), "no nvm segment: {result}"); + assert!( + result.contains(":/usr/local/bin:"), + "exe parent must precede the inherited PATH: {result}" + ); + assert!( + result.ends_with(":/sentinel/inherited"), + "inherited PATH must be appended last when no shell_path: {result}" + ); } - /// On Unix, supplying a `shell_path` must NOT trigger the Windows process-PATH - /// fallback — the output must be byte-identical to what it was before this - /// fix. + /// On Unix with no login-shell PATH, `build_augmented_path` must fall back to + /// the inherited process PATH — otherwise the child gets only Buzz-managed + /// dirs and loses every system binary (`curl`, `sh`, `tar`). #[cfg(unix)] #[test] - fn unix_shell_path_output_unchanged_by_windows_fallback_logic() { + fn unix_appends_process_path_when_no_shell_path() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", "/usr/bin:/bin"); + + let result = build_augmented_path(Some(PathBuf::from("/home/user")), None, None, None); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + let result = result.expect("path must not be None with a home dir"); + assert!( + result.starts_with("/home/user/.local/bin:"), + "home/.local/bin must be first: {result}" + ); + assert!( + result.ends_with(":/usr/bin:/bin"), + "process PATH must be last: {result}" + ); + } + + /// On Unix, supplying a `shell_path` must NOT also append the inherited + /// process PATH — the login-shell PATH already carries the native entries. + #[cfg(unix)] + #[test] + fn unix_shell_path_suppresses_inherited_fallback() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", "/should/not/appear"); + let result = build_augmented_path( Some(PathBuf::from("/home/user")), None, Some("/usr/local/bin:/usr/bin:/bin".to_string()), None, ); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + let result = result.expect("path"); assert!( result.ends_with(":/usr/local/bin:/usr/bin:/bin"), @@ -334,8 +390,8 @@ mod tests { // ── Pure policy and composition tests — run on every host ──────────────────── // // These test `should_use_inherited` and `compose_path_entries` with explicit -// inputs, so they run on macOS/Linux CI and validate the Windows policy -// behavior without touching process state or requiring a Windows target. +// inputs, so they run on macOS/Linux CI and validate the cross-platform +// fallback policy without touching process state or requiring a Windows target. #[cfg(test)] mod compose_tests { use super::{compose_path_entries, is_batch_shim, should_use_inherited}; @@ -347,43 +403,37 @@ mod compose_tests { // ── should_use_inherited policy matrix ──────────────────────────────────── - /// Windows + no shell path + has local context → must use inherited. + /// No shell path + has local context → must use inherited, on every OS. + /// This is the steady state on Windows (login_shell_path() is always None) + /// and the failure mode on Unix (login shell exited non-zero or printed + /// nothing); in both the child would otherwise get no native PATH entries. #[test] - fn policy_windows_no_shell_with_context_uses_inherited() { + fn policy_no_shell_with_context_uses_inherited() { assert!( - should_use_inherited(false, true, true), - "Windows, no shell path, has context → must append inherited" + should_use_inherited(false, true), + "no shell path, has context → must append inherited" ); } - /// Windows + shell path present → must NOT use inherited (login path covers it). + /// Shell path present → must NOT use inherited (login path covers it). #[test] - fn policy_windows_shell_path_present_suppresses_inherited() { + fn policy_shell_path_present_suppresses_inherited() { assert!( - !should_use_inherited(true, true, true), - "Windows, shell path present → must not append inherited" + !should_use_inherited(true, true), + "shell path present → must not append inherited" ); } - /// Windows + no local context → must NOT use inherited (no ambient state). + /// No local context → must NOT use inherited (no ambient state). #[test] - fn policy_windows_no_local_context_suppresses_inherited() { + fn policy_no_local_context_suppresses_inherited() { assert!( - !should_use_inherited(false, false, true), - "Windows, no local context → must not append inherited" - ); - } - - /// Non-Windows → never use inherited, regardless of other flags. - #[test] - fn policy_non_windows_never_uses_inherited() { - assert!( - !should_use_inherited(false, true, false), - "non-Windows must never append inherited PATH" + !should_use_inherited(false, false), + "no local context → must not append inherited" ); assert!( - !should_use_inherited(false, false, false), - "non-Windows + no context must never append inherited PATH" + !should_use_inherited(true, false), + "no local context → must not append inherited even with a shell path" ); } @@ -498,23 +548,25 @@ mod compose_tests { // compute the same `should_use_inherited` decision for equivalent inputs. // Tests the policy function directly to confirm the wrappers can't drift. - /// Exhaustive truth-table for `should_use_inherited` — all four input - /// combinations that affect real callers. Confirms the policy is correct - /// before either wrapper binds to it. + /// Exhaustive truth-table for `should_use_inherited` — every input + /// combination. Confirms the policy is correct before either wrapper binds + /// to it. The rule is OS-independent: the inherited PATH is the floor + /// whenever no login-shell PATH was obtained, because the alternative is a + /// child with no native binaries at all. #[test] fn should_use_inherited_policy_truth_table() { - // (had_shell, has_context, is_windows) → expected + // (had_shell, has_context) → expected let cases = [ - (false, true, true, true), // Windows, no shell, context → USE - (true, true, true, false), // Windows, shell present → NO - (false, false, true, false), // Windows, no context → NO - (false, true, false, false), // non-Windows → NO + (false, true, true), // no shell PATH, context → USE (the floor) + (true, true, false), // shell PATH present → NO (already covered) + (false, false, false), // no context → NO (no ambient-only PATH) + (true, false, false), // no context → NO, shell PATH irrelevant ]; - for (had_shell, has_ctx, is_win, expected) in cases { - let result = should_use_inherited(had_shell, has_ctx, is_win); + for (had_shell, has_ctx, expected) in cases { + let result = should_use_inherited(had_shell, has_ctx); assert_eq!( result, expected, - "policy mismatch: had_shell={had_shell} has_ctx={has_ctx} is_win={is_win}" + "policy mismatch: had_shell={had_shell} has_ctx={has_ctx}" ); } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs new file mode 100644 index 0000000000..37eb5659a4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -0,0 +1,469 @@ +use super::*; + +/// Binary name fragments for all known agent/harness processes that Buzz +/// may spawn. Used by `process_belongs_to_us()` and the orphan sweep to +/// identify processes we should clean up. Both hyphenated and underscored +/// variants are listed because macOS `proc_name()` and Linux `/proc/comm` +/// may report either form depending on how the binary was built. +pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ + "buzz-acp", + "buzz_acp", + "buzz-agent", + "buzz_agent", + "claude-agent-acp", + "claude_agent_acp", + "claude-code-acp", + "claude_code_acp", + "codex-acp", + "codex_acp", + "goose", + // buzz-dev-mcp's multicall personalities (rg, tree, buzz, + // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call + // invocations — not listed here. + "buzz-dev-mcp", + "buzz_dev_mcp", +]; + +/// Script interpreters that may host managed agent wrappers (e.g. npm shims). +/// A process whose name matches here is NOT immediately claimed — it must also +/// carry `BUZZ_MANAGED_AGENT` in its environment (checked by the caller via +/// `process_has_buzz_marker()`). This avoids sweeping unrelated node processes. +pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node"]; + +/// Check if a process name matches any of our known agent binaries. +/// Uses exact match or prefix-with-separator to avoid false positives +/// (e.g. `"goose"` must not match `"mongoose"`). +pub(super) fn name_matches_known_binary(name: &str) -> bool { + KNOWN_AGENT_BINARIES.iter().any(|&binary| { + name == binary || { + name.starts_with(binary) && { + let rest = &name[binary.len()..]; + rest.starts_with('-') || rest.starts_with('_') || rest.starts_with('.') + } + } + }) +} + +/// Check if a process name is a known script interpreter that may be hosting +/// a managed agent wrapper (e.g. `node` running an npm shim for `codex-acp`). +/// Callers must additionally verify `BUZZ_MANAGED_AGENT` ownership. +pub(super) fn name_matches_interpreter(name: &str) -> bool { + KNOWN_SCRIPT_INTERPRETERS.contains(&name) +} + +#[cfg(unix)] +pub(crate) fn process_is_running(pid: u32) -> bool { + // Use libc::kill with signal 0 instead of forking a subprocess. + // Returns true only if the process exists AND we can signal it. + // Returns false for non-existent PIDs (ESRCH) and PIDs owned by + // other users (EPERM) — callers should not interact with those. + unsafe { libc::kill(pid as i32, 0) == 0 } +} + +#[cfg(not(unix))] +pub(crate) fn process_is_running(_pid: u32) -> bool { + false +} + +/// Check if a PID belongs to a known agent process we spawned. +/// Returns false for recycled PIDs that now belong to other processes. +#[cfg(target_os = "macos")] +pub(crate) fn process_belongs_to_us(pid: u32) -> bool { + // Use proc_name() from libproc to get the process name without spawning + // a subprocess. + extern "C" { + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; + } + let mut buf = [0u8; 1024]; + let len = unsafe { + proc_name( + pid as i32, + buf.as_mut_ptr() as *mut libc::c_void, + buf.len() as u32, + ) + }; + if len <= 0 { + return false; + } + let name = String::from_utf8_lossy(&buf[..len as usize]); + // Fall through for script interpreters (e.g. `node` hosting an npm shim): + // the caller's `process_has_buzz_marker()` check decides true ownership. + name_matches_known_binary(&name) || name_matches_interpreter(&name) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn process_belongs_to_us(pid: u32) -> bool { + // First try /proc//comm. Note: comm is truncated to 15 bytes on Linux, + // so binaries with names longer than 15 chars (e.g. "claude-agent-acp") + // will never match here. + if let Ok(name) = std::fs::read_to_string(format!("/proc/{pid}/comm")) { + if name_matches_known_binary(name.trim()) { + return true; + } + // Interpreter check: `node` is 4 bytes, never truncated. + if name_matches_interpreter(name.trim()) { + return true; + } + } + + // Fallback: read /proc//exe which is a symlink to the full binary path. + // This is not subject to the 15-byte truncation limit. + if let Ok(exe_path) = std::fs::read_link(format!("/proc/{pid}/exe")) { + if let Some(basename) = exe_path.file_name().and_then(|n| n.to_str()) { + // Fall through for script interpreters — caller checks the marker. + return name_matches_known_binary(basename) || name_matches_interpreter(basename); + } + } + + false +} + +#[cfg(not(unix))] +pub(crate) fn process_belongs_to_us(_pid: u32) -> bool { + false +} + +/// The value stamped into the `BUZZ_MANAGED_AGENT` env var of every agent we +/// spawn, identifying *which* desktop instance owns it. We use the app's bundle +/// identifier (`xyz.block.buzz.app` for release, `xyz.block.buzz.app.dev` +/// for `just dev`) because it is stable across restarts — a relaunched dev +/// instance still recognizes its own previously-spawned agents as reclaimable, +/// while never matching another instance's (e.g. a dev build never reaps a DMG +/// build's agents, and vice versa). This is what lets two Buzzs coexist on +/// one machine without one's cleanup nuking the other's agents. +pub(crate) fn current_instance_id(app: &AppHandle) -> String { + app.config().identifier.clone() +} + +/// Build the full `BUZZ_MANAGED_AGENT=` env entry we match +/// against when scanning processes. Kept here so the spawn stamp and the sweep +/// matcher can never drift apart. +pub(super) fn buzz_marker_entry(instance_id: &str) -> Vec { + format!("BUZZ_MANAGED_AGENT={instance_id}").into_bytes() +} + +/// Check if a running process is one of *our* managed agents: it must carry +/// `BUZZ_MANAGED_AGENT=` in its environment, where `instance_id` +/// is this desktop instance's id. A process stamped with a *different* instance +/// id belongs to another live Buzz app and must never be reaped here. +#[cfg(target_os = "macos")] +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { + let marker = buzz_marker_entry(instance_id); + let Some(buf) = sweep::procargs2_buffer(pid) else { + return false; + }; + + // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...] + if buf.len() < std::mem::size_of::() { + return false; + } + let mut n_args: libc::c_int = 0; + unsafe { + std::ptr::copy_nonoverlapping( + buf.as_ptr(), + &mut n_args as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + } + let mut pos = std::mem::size_of::(); + + // Skip exec path (scan to first null). + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + // Skip null padding between exec path and argv[0]. + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + // Skip argc argument strings. + let mut args_remaining = n_args; + while args_remaining > 0 && pos < buf.len() { + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + args_remaining -= 1; + } + // Remaining bytes are null-delimited environment strings. + buf[pos..].split(|&b| b == 0).any(|entry| entry == marker) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { + let marker = buzz_marker_entry(instance_id); + let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else { + return false; + }; + data.split(|&b| b == 0).any(|entry| entry == marker) +} + +#[cfg(not(unix))] +pub(crate) fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { + false +} + +#[cfg(unix)] +fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> { + let pgid = -(pid as i32); + + if unsafe { libc::kill(pgid, signal) } == 0 { + return Ok(()); + } + + let group_err = std::io::Error::last_os_error(); + if !process_is_running(pid) { + return Ok(()); + } + + // Some local agent trees can no longer be signalled as a process group + // (for example if the leader changed groups, or macOS returns EPERM for one + // descendant). Fall back to the leader PID so stop/delete can still recover. + if matches!( + group_err.raw_os_error(), + Some(libc::EPERM) | Some(libc::ESRCH) + ) { + if unsafe { libc::kill(pid as i32, signal) } == 0 { + return Ok(()); + } + + let leader_err = std::io::Error::last_os_error(); + if leader_err.raw_os_error() == Some(libc::ESRCH) || !process_is_running(pid) { + return Ok(()); + } + + return Err(format!("failed to {action} process {pid}: {leader_err}")); + } + + Err(format!( + "failed to {action} process group {pid}: {group_err}" + )) +} + +#[cfg(unix)] +pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // Try graceful shutdown first (SIGTERM to the group). + signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; + + // Wait up to 1s for graceful exit. + for _ in 0..10 { + if !process_is_running(pid) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + // Escalate to SIGKILL on the entire group. + signal_process_group_or_leader(pid, libc::SIGKILL, "kill")?; + + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // No job handle is available on this path (e.g. after an app restart, when + // we only recovered the PID from the record), so fall back to taskkill on + // the whole tree. + super::super::process_lifecycle::taskkill_tree(pid) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> { + Err("managed agent shutdown after app restart is not supported on this platform".to_string()) +} + +/// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL +/// any survivors. Uses `-pid` to kill the entire process group — if an +/// orphaned agent called `setsid()`, it IS the group leader, so this +/// reaches its children too. +#[cfg(unix)] +fn sigterm_then_sigkill(pids: &[i32]) { + // Send SIGTERM to each process group. Track whether any signal was + // actually delivered so we can skip the sleep when everything is + // already gone. + let mut any_signalled = false; + for &pid in pids { + if unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 { + any_signalled = true; + } + } + + if !any_signalled { + return; + } + + std::thread::sleep(std::time::Duration::from_millis(200)); + + for &pid in pids { + // Check if the group has any living members, not just the leader. + // kill(-pid, 0) returns 0 if ANY member of the group is signalable. + if unsafe { libc::kill(-pid, 0) } == 0 { + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + } +} + +/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, +/// and signal the groups. An orphaned grandchild (e.g. `goose` or `buzz-dev-mcp`) +/// whose harness has exited retains the harness's PGID — signaling that PGID +/// kills the entire orphaned subtree. Falls back to the candidate PID itself +/// when PGID resolution fails (process may have exited between detection and +/// kill). +#[cfg(target_os = "macos")] +pub(super) fn resolve_pgids_and_kill(candidate_pids: &[i32]) { + let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); + let mut pgids = std::collections::HashSet::new(); + for &pid in candidate_pids { + let pgid = unsafe { libc::getpgid(pid) }; + if pgid > 0 { + pgids.insert(pgid); + } else { + // Process may have exited; try signaling it directly as a group. + pgids.insert(pid); + } + } + // PID-recycling guard: if a resolved PGID is alive but isn't one of our + // orphan candidates, the old harness PID was recycled by a new process + // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); + pgids.retain(|&pgid| { + if candidate_set.contains(&pgid) { + return true; + } + let alive = unsafe { libc::kill(pgid, 0) } == 0; + !alive + }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } + let unique: Vec = pgids.into_iter().collect(); + sigterm_then_sigkill(&unique); +} + +/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, +/// and signal the groups. Linux variant reads PGID from /proc//stat. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn resolve_pgids_and_kill(candidate_pids: &[i32]) { + let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); + let mut pgids = std::collections::HashSet::new(); + for &pid in candidate_pids { + if let Some((_, pgid)) = sweep::proc_stat_ppid_pgid_linux(pid as u32) { + pgids.insert(pgid as i32); + } else { + // Process may have exited; try signaling it directly as a group. + pgids.insert(pid); + } + } + // PID-recycling guard: if a resolved PGID is alive but isn't one of our + // orphan candidates, the old harness PID was recycled by a new process + // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); + pgids.retain(|&pgid| { + if candidate_set.contains(&pgid) { + return true; + } + let alive = unsafe { libc::kill(pgid, 0) } == 0; + !alive + }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } + let unique: Vec = pgids.into_iter().collect(); + sigterm_then_sigkill(&unique); +} + +pub(crate) fn valid_agent_runtime_receipt( + path: &std::path::Path, + receipt: &super::super::ManagedAgentRuntimeReceipt, + instance_id: &str, +) -> bool { + valid_agent_runtime_receipt_with( + path, + receipt, + instance_id, + process_is_running, + process_has_buzz_marker, + ) +} + +/// Injectable version of `valid_agent_runtime_receipt` for testing. +/// `is_running(pid)` and `has_marker(pid, instance_id)` can be substituted by +/// test doubles without spawning real processes. +pub(crate) fn valid_agent_runtime_receipt_with( + path: &std::path::Path, + receipt: &super::super::ManagedAgentRuntimeReceipt, + instance_id: &str, + is_running: impl Fn(u32) -> bool, + has_marker: impl Fn(u32, &str) -> bool, +) -> bool { + let Ok(canonical) = + ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) + else { + return false; + }; + canonical == receipt.key + && path.file_name().and_then(|name| name.to_str()) + == Some(&format!("{}.json", receipt.key.runtime_id())) + && receipt.desktop_instance_id == instance_id + && is_running(receipt.pid) + // Receipts are written by THIS instance at spawn time, so they are + // Buzz-owned by construction. Marker-only ownership: custom-harness + // binaries (not in KNOWN_AGENT_BINARIES) must not be rejected by a + // name gate — see the sweep ownership rule in runtime/orphan_sweep.rs. + && has_marker(receipt.pid, &receipt.desktop_instance_id) +} + +pub(super) fn terminate_runtime_receipt_with( + path: &std::path::Path, + receipt: &super::super::ManagedAgentRuntimeReceipt, + terminate: impl FnOnce(u32) -> Result<(), String>, + mut is_running: impl FnMut(u32) -> bool, + remove: impl FnOnce(&std::path::Path), +) -> Result<(), String> { + terminate(receipt.pid)?; + for _ in 0..20 { + if !is_running(receipt.pid) { + remove(path); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err(format!( + "prior runtime {} for pair {} on {} did not exit", + receipt.pid, receipt.key.pubkey, receipt.key.relay_url + )) +} + +/// Replace a valid prior-session process before registering a new child for +/// the same pair. The caller must hold the runtime transition lock so receipt +/// inspection, termination, spawn, and registration cannot race shutdown or +/// another start. +pub(crate) fn terminate_untracked_pair_runtime( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, +) -> Result<(), String> { + let instance_id = current_instance_id(app); + let Some((path, receipt)) = super::super::read_all_agent_runtime_receipts(app) + .into_iter() + .find(|(path, receipt)| { + receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) + }) + else { + return Ok(()); + }; + + terminate_runtime_receipt_with( + &path, + &receipt, + terminate_process, + process_is_running, + super::super::remove_agent_runtime_receipt_path, + ) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index fd758047c3..3f6ee996f6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,12 @@ use crate::managed_agents::known_acp_runtime; +// ── desktop binary name tests ─────────────────────────────────────────── + +#[test] +fn appimage_binary_matches_truncated_linux_comm_name() { + assert!(super::is_desktop_binary("buzz-desktop.bi")); +} + // ── buffer_contains_identifier tests ──────────────────────────────────── #[test] @@ -46,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""#); @@ -65,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!( @@ -168,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, @@ -287,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(), @@ -308,20 +317,16 @@ fn persona_with_provider( // that the old create-time env baking silently blocked. use crate::managed_agents::env_vars::{live_persona_env, merged_user_env}; -use crate::managed_agents::persona_events::persona_snapshot; use std::collections::BTreeMap; /// Apply a persona snapshot onto a record, mirroring `create_managed_agent`: -/// snapshotted prompt/model/provider/source_version are pinned, with the -/// system_prompt unwrapped (the persona always carries one). `env_vars` is -/// deliberately NOT touched — it stays agent overrides only. +/// links the record to `persona.id`, then delegates the actual snapshot +/// (prompt/model/provider/runtime/source_version) to the real production +/// `apply_persona_snapshot` — so a change to that function's behavior is +/// exercised by these tests instead of silently diverging from it. fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::AgentDefinition) { - let snapshot = persona_snapshot(persona); record.persona_id = Some(persona.id.clone()); - record.system_prompt = snapshot.system_prompt; - record.model = snapshot.model; - record.provider = snapshot.provider; - record.persona_source_version = Some(snapshot.source_version); + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); } fn persona_v( @@ -421,19 +426,31 @@ fn agent_env_overrides_win_over_persona_env_at_spawn() { } #[test] -fn orphaned_agent_spawns_from_its_own_overrides() { - // Persona deleted: the live merge degrades to the record's own overrides. +fn orphaned_agent_refused_at_spawn_boundary() { + // Persona deleted: `spawn_agent_child` must refuse before any process + // side effect, not silently degrade to the record's stale overrides. + // `require_resolved` on the shared resolver is the pure predicate + // `spawn_agent_child` checks first — this pins the contract without + // needing a real `AppHandle`. let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]); let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); record.env_vars = BTreeMap::from([("EXTRA".to_string(), "agent-value".to_string())]); pin_persona(&mut record, &persona); - let env = spawn_user_env(&record, &[]); - assert_eq!(env.get("EXTRA").map(String::as_str), Some("agent-value")); + // The persona is absent from the live catalog — same shape restore/start + // see when a persona was deleted on another device. + let no_personas: &[crate::managed_agents::AgentDefinition] = &[]; + let error = crate::managed_agents::effective_config::resolve_effective_config( + &record, + no_personas, + &Default::default(), + ) + .require_resolved() + .unwrap_err(); assert_eq!( - env.get("ANTHROPIC_API_KEY"), - None, - "a deleted persona's env must not linger" + error, + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + "an orphaned linked instance must be refused, not spawned from its own overrides" ); } @@ -693,14 +710,30 @@ fn grandchild_inherits_pgid_of_process_group_leader() { // spawns an intermediate child which in turn spawns a grandchild. // This mirrors the real tree: buzz-acp → goose → buzz-dev-mcp. // - // The intermediate `sh` uses exec to replace itself with another sh - // that backgrounds the grandchild, so the grandchild's ppid is the - // intermediate (not the harness). + // The intermediate `sh` backgrounds the grandchild and echoes its PID, + // so the grandchild's ppid is the intermediate (not the harness). + // + // The trailing `sleep 10` keeps the harness (the process group leader) + // alive through the assertions below: without it the harness exits as + // soon as the intermediate echoes, and under parallel test load it can + // be reaped before `getpgid(harness_pid)` runs (observed flake — + // getpgid returned -1). The group is killed in cleanup, so the sleep + // never runs to term. + // + // Absolute `/bin/sh` and `/bin/sleep` rather than bare names: parallel + // tests holding `lock_path_mutex` legitimately swap PATH to a tempdir, + // and this test doesn't need the lock — but a PATH lookup during the + // swap window fails with NotFound, and a child spawned during it + // inherits the poisoned PATH for its lifetime, so the script's inner + // lookups must be absolute too (observed flake). let mut harness = { - let mut cmd = Command::new("sh"); - cmd.args(["-c", "sh -c 'sleep 10 & echo $!' & wait $!"]) - .stdout(std::process::Stdio::piped()) - .process_group(0); + let mut cmd = Command::new("/bin/sh"); + cmd.args([ + "-c", + "/bin/sh -c '/bin/sleep 10 & echo $!' & wait $!; /bin/sleep 10", + ]) + .stdout(std::process::Stdio::piped()) + .process_group(0); cmd.spawn().expect("spawn harness") }; @@ -784,9 +817,15 @@ fn own_group_grandchild_detected_by_ancestor_walk() { // The test process is the "harness". Spawn an intermediate with its own // process group (mirrors the node shim). It backgrounds a grandchild // (sleep 30) and prints the grandchild PID so we can inspect it. + // + // Absolute `/bin/sh` and `/bin/sleep` — no PATH lookups anywhere in this + // tree. Parallel tests holding `lock_path_mutex` legitimately swap PATH to + // a tempdir; the outer spawn during that window fails with NotFound, and a + // child spawned during it inherits the poisoned PATH for its lifetime, so + // inner lookups must be absolute too (observed flake). let mut intermediate = { - let mut cmd = Command::new("sh"); - cmd.args(["-c", "sleep 30 & echo $!; wait"]) + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "/bin/sleep 30 & echo $!; wait"]) .stdout(std::process::Stdio::piped()) .process_group(0); cmd.spawn().expect("spawn intermediate") @@ -1003,3 +1042,275 @@ fn invalid_pubkey_resolves_no_pair_key() { // the summary must fall back to the stopped/legacy-pid path, not panic. assert!(super::resolve_workspace_pair_key("not-a-key", "", "wss://one.example").is_none()); } + +// ── Custom-harness orphan sweep coverage ───────────────────────────────────── +// +// The sweep/receipt ownership gate must include any process carrying the +// `BUZZ_MANAGED_AGENT` env marker, regardless of whether the binary name +// matches `KNOWN_AGENT_BINARIES`. Custom harnesses use arbitrary binary names +// so name-match alone would silently leak their orphans on crash. +// +// Previously: macOS used a two-check OR+AND pattern (equivalent to just marker), +// Linux used an AND-gate (name + marker) — wrong for custom harnesses. +// Fix: all platforms gate on `process_has_buzz_marker` alone; the receipt path +// is verified below via `valid_agent_runtime_receipt_with` (injectable), +// which no longer takes a name-check predicate at all — reinstating an +// AND-gate would be a signature change these tests would catch. + +// ── Collector-discriminating sweep tests (C-9 / Thufir F6) ────────────────── +// +// `kill_stale_tracked_processes_with` and `valid_agent_runtime_receipt_with` +// accept injectable predicates so the sweep logic can be verified without +// spawning real processes. These tests drive the injection path directly, +// discriminating on custom-harness vs known-binary vs marker presence. + +#[test] +fn kill_stale_custom_harness_with_marker_is_terminated() { + // A record with a PID not in the live runtime map and with the Buzz marker + // should be terminated even though the binary name is not in KNOWN_AGENT_BINARIES. + let mut record = minimal_record("pubkey-custom"); + record.runtime_pid = Some(9001); + let mut records = vec![record]; + let runtimes = std::collections::HashMap::new(); + + let mut killed = vec![]; + let changed = super::kill_stale_tracked_processes_with( + &mut records, + &runtimes, + |_pid| true, // simulate: marker present (custom harness we own) + |pid| { + killed.push(pid); + Ok(()) + }, + ); + + assert!(changed, "stale record with marker should mark changed"); + assert_eq!(killed, vec![9001u32], "marked process must be killed"); + assert!( + records[0].runtime_pid.is_none(), + "runtime_pid must be cleared" + ); +} + +#[test] +fn kill_stale_process_without_marker_is_skipped() { + // A record PID without the marker (not our process — e.g. custom binary + // from another tool) should be skipped for termination but still cleared. + let mut record = minimal_record("pubkey-foreign"); + record.runtime_pid = Some(9002); + let mut records = vec![record]; + let runtimes = std::collections::HashMap::new(); + + let mut killed = vec![]; + let changed = super::kill_stale_tracked_processes_with( + &mut records, + &runtimes, + |_pid| false, // simulate: no marker (not our process) + |pid| { + killed.push(pid); + Ok(()) + }, + ); + + assert!( + changed, + "stale record without marker should still mark changed" + ); + assert!( + killed.is_empty(), + "process without marker must not be killed" + ); + assert!( + records[0].runtime_pid.is_none(), + "runtime_pid must be cleared regardless" + ); +} + +#[test] +fn kill_stale_live_pair_is_not_touched() { + // A record whose PID is in the live runtime map is NOT stale — skip it entirely. + use crate::managed_agents::ManagedAgentRuntimeKey; + let pubkey = "aa".repeat(32); // 64 hex chars — satisfies ManagedAgentRuntimeKey validation + let mut record = minimal_record(&pubkey); + record.runtime_pid = Some(9003); + let key = ManagedAgentRuntimeKey::new(pubkey, "wss://relay.example").unwrap(); + let mut runtimes = std::collections::HashMap::new(); + // Insert a placeholder runtime — value shape doesn't matter for the key lookup. + runtimes.insert(key, make_pair_runtime_placeholder()); + let original_pid = record.runtime_pid; + let mut records = vec![record]; + + let mut killed = vec![]; + let changed = super::kill_stale_tracked_processes_with( + &mut records, + &runtimes, + |_pid| true, + |pid| { + killed.push(pid); + Ok(()) + }, + ); + + assert!(!changed, "live pair must not be marked changed"); + assert!(killed.is_empty(), "live pair process must not be killed"); + assert_eq!( + records[0].runtime_pid, original_pid, + "live pair runtime_pid must not be cleared" + ); +} + +#[test] +fn receipt_valid_with_marker_and_running() { + // Custom harness receipt: custom binary (not in KNOWN_AGENT_BINARIES), has_marker=true, is_running=true. + // Must be valid — marker is the authoritative gate. + use crate::managed_agents::ManagedAgentRuntimeKey; + let key = ManagedAgentRuntimeKey::new("bb".repeat(32), "wss://relay.example").unwrap(); + let receipt = receipt_fixture(key.clone()); + let path = std::path::PathBuf::from(format!("{}.json", key.runtime_id())); + + let valid = super::valid_agent_runtime_receipt_with( + &path, + &receipt, + "test-instance", + |_pid| true, // is_running + |_pid, _iid| true, // has_marker: true (custom binary — no name gate) + ); + assert!( + valid, + "custom harness with marker and running pid must be valid" + ); +} + +#[test] +fn receipt_invalid_known_binary_without_marker() { + // Known binary name but no marker — stray process, must not be valid. + use crate::managed_agents::ManagedAgentRuntimeKey; + let key = ManagedAgentRuntimeKey::new("cc".repeat(32), "wss://relay.example").unwrap(); + let receipt = receipt_fixture(key.clone()); + let path = std::path::PathBuf::from(format!("{}.json", key.runtime_id())); + + let valid = super::valid_agent_runtime_receipt_with( + &path, + &receipt, + "test-instance", + |_pid| true, // is_running + |_pid, _iid| false, // has_marker: false (not our process) + ); + assert!(!valid, "known binary without marker must not be valid"); +} + +#[test] +fn receipt_invalid_when_process_not_running() { + // Even with marker, a non-running process must not be valid. + use crate::managed_agents::ManagedAgentRuntimeKey; + let key = ManagedAgentRuntimeKey::new("dd".repeat(32), "wss://relay.example").unwrap(); + let receipt = receipt_fixture(key.clone()); + let path = std::path::PathBuf::from(format!("{}.json", key.runtime_id())); + + let valid = super::valid_agent_runtime_receipt_with( + &path, + &receipt, + "test-instance", + |_pid| false, // is_running: false + |_pid, _iid| true, // has_marker + ); + assert!( + !valid, + "non-running process must not be valid regardless of marker" + ); +} + +// ── Test helpers ──────────────────────────────────────────────────────────── + +fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{pubkey}", + "name": "test", + "private_key_nsec": "nsec1fake", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {{}}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }}"# + )) + .expect("minimal_record fixture") +} + +fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRuntime { + use std::process::{Command, Stdio}; + // Spawn a real child so ManagedAgentProcess's Child field is satisfied. + // `true` exits immediately with 0 — just a handle we need for type purposes. + // + // Absolute `/usr/bin/true` on unix (present on both macOS and Linux): + // parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a + // bare `true` lookup during that window fails with NotFound (observed + // flake). Windows keeps the PATH lookup — no test there swaps PATH. + #[cfg(unix)] + let program = "/usr/bin/true"; + #[cfg(windows)] + let program = "true"; + let child = Command::new(program) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn true for placeholder"); + let process = crate::managed_agents::ManagedAgentProcess { + child, + log_path: std::path::PathBuf::new(), + spawn_config_hash: 0, + setup_mode: false, + adapter_availability: None, + start_nonce: "test-nonce".to_string(), + #[cfg(windows)] + job: None, + }; + crate::managed_agents::ManagedAgentPairRuntime::starting(process) +} + +// ── restart_eligible tests ────────────────────────────────────────────── + +#[test] +fn restart_eligible_true_when_non_orphan_has_hash_drift() { + assert!(super::restart_eligible(false, true, false)); +} + +#[test] +fn restart_eligible_true_when_non_orphan_has_availability_drift() { + assert!(super::restart_eligible(false, false, true)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_hash_drift() { + // An orphan can never be restarted successfully — spawn refuses it — + // so hash drift alone must not surface "Restart required". + assert!(!super::restart_eligible(true, true, false)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_availability_drift() { + assert!(!super::restart_eligible(true, false, true)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_no_drift() { + assert!(!super::restart_eligible(true, false, false)); +} + +#[test] +fn restart_eligible_false_when_non_orphan_has_no_drift() { + assert!(!super::restart_eligible(false, false, false)); +} diff --git a/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs b/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs new file mode 100644 index 0000000000..a1044b31f4 --- /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 4d80549288..648cc62bbe 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -28,24 +28,14 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use super::{ + effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, - persona_events::apply_persona_snapshot, - resolve_effective_agent_env, + persona_events::preview_prospective_persona_snapshot, + runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, GlobalAgentConfig, }; -/// The prompt a spawn would actually deliver: `Some("")` collapses to `None` -/// because an empty `BUZZ_ACP_SYSTEM_PROMPT` is no prompt. -/// -/// The single source of truth for the spawn env write AND the config hash. -pub(crate) fn effective_spawn_prompt(record: &ManagedAgentRecord) -> Option { - record - .system_prompt - .clone() - .filter(|prompt| !prompt.is_empty()) -} - /// Resolve the current instructions for this instance's deployment-time team binding. /// A deleted team deliberately degrades to no team section. pub(crate) fn effective_team_instructions( @@ -76,44 +66,75 @@ pub(crate) fn spawn_config_hash( // restart would actually run. Idempotent, so the spawn-time stamp // (post-snapshot record) and later recomputes (persisted record) agree // when nothing changed. The persona env itself reaches the hash through - // `resolve_effective_agent_env` below; `persona_source_version` is set on + // the descriptor's layered env below; `persona_source_version` is set on // the clone but is not a hash input. - let mut record = record.clone(); - if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { - apply_persona_snapshot(&mut record, persona); - } - } + let record = preview_prospective_persona_snapshot(record, personas); let record = &record; - let effective_command = crate::managed_agents::record_agent_command(record, personas); - let runtime_meta = known_acp_runtime(&effective_command); - let effective = resolve_effective_agent_env(record, personas, runtime_meta, global); + // Resolve command, args, and env via the single typed descriptor — same path + // as spawn_agent_child. Dangling harness id falls back to the infallible + // record_agent_command (no-op: a dangling harness can't be spawned, so the + // hash never matters for that agent). + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global) + .unwrap_or_else(|_| { + let cmd = crate::managed_agents::record_agent_command(record, personas); + let args = normalize_agent_args(&cmd, record.agent_args.clone()); + crate::managed_agents::readiness::EffectiveHarnessDescriptor { + command: cmd, + args, + env: Default::default(), + } + }); + let runtime_meta = known_acp_runtime(&descriptor.command); let mut hasher = DefaultHasher::new(); // Harness identity and derivations (live-persona-resolved, like spawn). record.acp_command.hash(&mut hasher); - effective_command.hash(&mut hasher); - normalize_agent_args(&effective_command, record.agent_args.clone()).hash(&mut hasher); + descriptor.command.hash(&mut hasher); + descriptor.args.hash(&mut hasher); runtime_meta .and_then(|r| r.mcp_command) .unwrap_or("") .hash(&mut hasher); - // Effective env layering (baked floor → runtime metadata → user env). - // BTreeMap iteration is ordered, so this is deterministic. - effective.env.hash(&mut hasher); + // Effective env layering (baked floor → runtime metadata → definition env + // → global → persona → agent). BTreeMap iteration is ordered, deterministic. + descriptor.env.hash(&mut hasher); // Record fields the spawn env writes read directly. The relay is hashed // resolved: every record spawns on the workspace relay (legacy pins // ignored), so a workspace relay change must trip the badge. crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); - // Prompt and runtime-layered team instructions use the same resolver as spawn. - effective_spawn_prompt(record).hash(&mut hasher); + // Team instructions use the same resolver as spawn. effective_team_instructions(record, teams).hash(&mut hasher); - record.model.hash(&mut hasher); - record.provider.hash(&mut hasher); + // Prompt, model, and provider all come from ONE `resolve_effective_config` + // call — the SAME resolve `spawn_agent_child` performs for the env write, + // so env write and this badge cannot disagree. An orphaned link (missing + // definition) hashes as if all three were absent: `spawn_agent_child` + // refuses to spawn an orphan regardless, so this is a display-only + // convenience, not the spawn gate. + let (resolved_prompt, resolved_model, resolved_provider) = + match resolve_effective_config(record, personas, global) { + EffectiveConfigResult::Resolved(cfg) => { + (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) + } + EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), + }; + resolved_prompt.hash(&mut hasher); + resolved_model.hash(&mut hasher); + resolved_provider.hash(&mut hasher); + // Session title: the same resolve `spawn_agent_child` performs for its env + // write, so a rename raises the restart badge. Skipped when a user env + // override shadows it — spawn writes the title BEFORE the user env layer, + // so the override is what actually runs, and it already reaches this hash + // through `descriptor.env` above. Hashing the record-derived value under an + // override would badge a rename that changes nothing. + let effective_session_title = (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR)) + .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) + .flatten(); + effective_session_title.hash(&mut hasher); record.auth_tag.hash(&mut hasher); record.respond_to.as_str().hash(&mut hasher); // The allowlist is hashed as the env receives it: spawn sets 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 6438b3b37d..f4ad404814 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(), @@ -441,18 +445,323 @@ fn missing_definition_leaves_materialized_runtime_in_hash() { ); } +// ── Global default trips hash for linked inherited agents ───────────────── + #[test] -fn effective_spawn_prompt_matches_hash_semantics() { - // The env write and the hash share effective_spawn_prompt — this row - // pins the helper's own semantics so a refactor of either caller cannot - // silently diverge from the contract. - let mut r = record(); - r.system_prompt = Some(String::new()); - assert_eq!( - effective_spawn_prompt(&r), - None, - "empty collapses to absent" +fn global_model_change_trips_hash_for_linked_inherited_agent() { + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.model = Some("stale-record-model".into()); + + let personas = vec![persona("p1", Some("goose"), "prompt")]; + + let global_a = GlobalAgentConfig { + model: Some("model-a".to_string()), + provider: Some("prov-a".to_string()), + ..Default::default() + }; + let global_b = GlobalAgentConfig { + model: Some("model-b".to_string()), + provider: Some("prov-b".to_string()), + ..Default::default() + }; + + let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); + let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + + assert_ne!( + hash_a, hash_b, + "changing the global default must trip the hash for a linked inherited agent" + ); +} + +#[test] +fn global_model_change_trips_hash_without_model_env_var() { + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.agent_command = "some-harness-without-model-env".into(); + + let personas = vec![{ + let mut p = persona("p1", None, "prompt"); + p.model = None; + p.provider = None; + p + }]; + + let global_a = GlobalAgentConfig { + model: Some("model-a".to_string()), + ..Default::default() + }; + let global_b = GlobalAgentConfig { + model: Some("model-b".to_string()), + ..Default::default() + }; + + let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); + let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + + assert_ne!( + hash_a, hash_b, + "global model change must trip hash even without a model_env_var runtime" + ); +} + +#[test] +fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { + // Regression for the split-resolve defect: prompt used to be read from + // the record's own (possibly Phase-A-snapshot-stale) bytes while + // model/provider were resolved live from the definition. A definition + // edit landing between a caller's snapshot apply and spawn could hand a + // fresh model/provider to a stale prompt, and the hash (which already + // resolved model/provider live) would silently agree with a spawn that + // wrote the stale prompt. Now both come from one `resolve_effective_config` + // call, so a record whose own `system_prompt` bytes disagree with the + // live definition must hash exactly as if the record carried the + // definition's prompt verbatim — the record's prompt bytes are inert for + // a linked instance. + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.system_prompt = Some("stale prompt on record".into()); + + let mut matching_bytes = rec.clone(); + matching_bytes.system_prompt = Some("live prompt".into()); + + let personas = [persona("p1", Some("goose"), "live prompt")]; + + assert_eq!( + spawn_config_hash( + &rec, + &personas, + &[], + "wss://ws.example", + &Default::default() + ), + spawn_config_hash( + &matching_bytes, + &personas, + &[], + "wss://ws.example", + &Default::default() + ), + "record's own system_prompt bytes must not affect the hash of a linked instance" + ); +} + +#[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 + // like model/provider — a definition prompt edit trips the hash even + // though the record's own (stale) system_prompt bytes are unchanged. + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.system_prompt = Some("stale".into()); + + let before = [persona("p1", Some("goose"), "old definition prompt")]; + let after = [persona("p1", Some("goose"), "new definition prompt")]; + + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + "linked instance prompt must resolve from the live definition, not stale record bytes" + ); +} + +// ── I2: definition args and env reach spawn_config_hash ────────────────────── +// +// These tests prove that editing a custom harness definition's args or env +// changes spawn_config_hash, which trips the "restart required" badge. +// They would fail if spawn_config_hash used only record.agent_args without +// falling back to definition args, or if resolve_effective_agent_env did not +// include definition env. + +/// When a record has no instance args but the definition has default args, +/// changing the definition args changes the spawn hash. This would fail if +/// spawn_config_hash used only record.agent_args. +#[test] +fn spawn_hash_changes_when_definition_default_args_change() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + // The loaded-harness registry is process-global: a parallel test re-warming + // it between the two hash computations makes both resolve to no-definition + // and h1 == h2 (observed on Windows CI). + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + + // Write v1 definition (args: ["--mode", "v1"]). + fs::write( + dir.path().join("my-def.json"), + r#"{"id":"my-def","label":"My Def","command":"my-def-bin","args":["--mode","v1"]}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let mut r = record(); + r.runtime = Some("my-def".into()); + r.agent_args = vec![]; // no instance args → definition args are used + + let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + + // Update to v2 args and re-warm (simulating save + transactional refresh). + fs::write( + dir.path().join("my-def.json"), + r#"{"id":"my-def","label":"My Def","command":"my-def-bin","args":["--mode","v2"]}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + + assert_ne!( + h1, h2, + "changing definition default args must change the spawn hash" + ); +} + +/// When a definition has env vars, adding them changes the spawn hash. This +/// proves resolve_effective_agent_env includes definition env in the layering. +#[test] +fn spawn_hash_changes_when_definition_env_changes() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + // Serialize against parallel registry re-warms (see the args test above). + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + + // Write definition without env. + fs::write( + dir.path().join("env-def.json"), + r#"{"id":"env-def","label":"Env Def","command":"env-def-bin"}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let mut r = record(); + r.runtime = Some("env-def".into()); + + let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + + // Update to include env and re-warm. + fs::write( + dir.path().join("env-def.json"), + r#"{"id":"env-def","label":"Env Def","command":"env-def-bin","env":{"MY_FLAG":"1"}}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + + assert_ne!(h1, h2, "adding definition env must change the spawn hash"); +} + +/// Instance-level args win over definition default args (non-empty instance +/// args must NOT be overridden by the definition). The hash must match a record +/// that has the same effective args from either source. +#[test] +fn spawn_hash_instance_args_win_over_definition_args() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + // Serialize against parallel registry re-warms (see the args test above). + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("arg-def.json"), + r#"{"id":"arg-def","label":"Arg Def","command":"arg-def-bin","args":["--def-arg"]}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let mut r_instance = record(); + r_instance.runtime = Some("arg-def".into()); + r_instance.agent_args = vec!["--instance-arg".to_string()]; + + let mut r_no_instance = record(); + r_no_instance.runtime = Some("arg-def".into()); + r_no_instance.agent_args = vec![]; + + let h_instance = spawn_config_hash(&r_instance, &[], &[], "ws://relay", &Default::default()); + let h_no_instance = + spawn_config_hash(&r_no_instance, &[], &[], "ws://relay", &Default::default()); + + assert_ne!( + h_instance, h_no_instance, + "instance args and definition args must produce different hashes" ); - r.system_prompt = Some("real".into()); - assert_eq!(effective_spawn_prompt(&r).as_deref(), Some("real")); } diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index b9c6c7e6cd..f6f89ed898 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 0000000000..73567bb915 --- /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 d88a362723..96082acc76 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 140ac3cab9..1ffa60eda9 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 af4908911b..3d8e0ed02b 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(), @@ -259,13 +279,19 @@ pub struct ManagedAgentRecord { /// Desired LLM model ID. Matches AgentModelInfo.id from discovery. /// The harness re-discovers the correct ACP switching metadata at session /// creation by matching this ID against the fresh session/new response. + /// For a linked instance this is a legacy/display snapshot only — spawn + /// and deploy resolve the effective model from the definition, never + /// from this field (see `effective_config::resolve_effective_config`). + /// For a definition-less instance this field is authoritative. #[serde(default)] pub model: Option, - /// LLM inference provider snapshotted from the persona at create time - /// (e.g. 'databricks', 'anthropic'). Spawn and deploy read this, never the - /// live persona — so the agent stays pinned to the provider it was created - /// with across restarts. `#[serde(default)]` so pre-existing records - /// deserialize as `None` and get backfilled on first load. + /// LLM inference provider. For a linked instance this is a legacy/display + /// snapshot only — spawn and deploy resolve the effective provider from + /// the definition, never from this field (see + /// `effective_config::resolve_effective_config`). For a definition-less + /// instance this field is authoritative. `#[serde(default)]` so + /// pre-existing records deserialize as `None` and get backfilled on + /// first load. #[serde(default)] pub provider: Option, /// Content hash of the persona at the time this agent was created — the @@ -362,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`, @@ -372,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`/ @@ -390,12 +427,15 @@ pub struct ManagedAgentRecord { /// inference through Buzz's relay-mesh local endpoint; the `model_ref` is /// the served model id to route to. `None` is a normal agent. /// - /// This is the source of truth for "is this a mesh agent + which model" — - /// replacing the old practice of sniffing it back out of `env_vars` - /// (`relay_mesh_config`). Spawn-time env vars are *derived from* this, not - /// the other way around. `#[serde(default)]` so pre-existing saved records - /// deserialize as `None` and are resolved via the env-var fallback until - /// they are rewritten with this field. + /// Not the source of truth. `provider == "relay-mesh"` is, resolved through + /// `effective_config::resolve_effective_config`; spawn-time env vars are + /// derived from that resolution. This field is retained solely as a + /// backward-compatibility signal for records written before the record had + /// a `provider` field, and is consulted only for definition-less records + /// that carry no provider — after which the env-var preset is the last + /// fallback. A linked instance's marker is never read: its definition is + /// authoritative. `#[serde(default)]` so records predating the field + /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, } @@ -456,6 +496,10 @@ pub struct ManagedAgentSummary { pub pubkey: String, pub name: String, pub persona_id: Option, + /// The record's harness/runtime id (mirror of `ManagedAgentRecord.runtime`). + /// Lets the UI count agents referencing a harness definition (e.g. in the + /// delete-confirmation flow). `None` = inherit from the linked persona. + pub runtime: Option, pub team_id: Option, pub relay_url: String, pub acp_command: String, @@ -478,7 +522,11 @@ pub struct ManagedAgentSummary { pub system_prompt: Option, pub avatar_url: Option, pub model: Option, - /// LLM inference provider, from the agent's pinned record snapshot. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_source: Option, + /// LLM inference provider, resolved the same way as `model`/`model_source` + /// (definition → global for linked instances; instance → global for + /// definition-less instances). `None` for an orphaned instance. pub provider: Option, /// `true` when the linked persona has been edited since this agent was /// created — the running agent uses the older pinned snapshot. The UI @@ -487,9 +535,11 @@ pub struct ManagedAgentSummary { /// persona is gone, so there is nothing newer to drift toward). pub persona_out_of_date: bool, /// `true` when the agent was created from a persona that no longer exists. - /// Distinct from out-of-date: there is no current persona to respawn into, - /// so the UI should not prompt a respawn — the pinned snapshot is all the - /// config that remains. + /// Distinct from out-of-date: there is no current persona to respawn into. + /// An orphaned agent also cannot be (re)started — `spawn_agent_child` + /// refuses it (see `effective_config::resolve_effective_config`'s + /// `OrphanedInstance` arm via `require_resolved`) — so the UI + /// should surface that it's stuck, not merely stale. pub persona_orphaned: bool, /// `true` when the running process was spawned with a config that no /// longer matches what a spawn would use today — a plain restart would @@ -565,6 +615,19 @@ pub enum AuthStatus { Unknown, } +/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string +/// so the TypeScript consumer can switch on it without numeric comparisons. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HarnessSource { + /// Compiled into the app — one of the four first-class runtimes. + Builtin, + /// Static preset entry with bundled logo, PATH-probed, not editable/deletable. + Preset, + /// Loaded at runtime from the user's `custom_harnesses/` directory. + Custom, +} + #[derive(Debug, Clone, Serialize)] pub struct AcpRuntimeCatalogEntry { pub id: String, @@ -596,6 +659,19 @@ pub struct AcpRuntimeCatalogEntry { /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. #[serde(skip_serializing_if = "Option::is_none")] pub login_hint: Option, + /// Whether this entry came from the compiled-in catalog or a user-supplied + /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. + pub source: HarnessSource, + /// Definition-level environment variables for `source: custom` entries. + /// + /// Populated from `HarnessDefinition.env` so the edit form can read them + /// back and the user doesn't silently lose env vars when saving. Always + /// empty for `builtin` and `preset` entries (those env values come from the + /// runtime metadata path, not user-editable JSON). + /// + /// Skipped in serialization when empty to keep the catalog payload compact. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub definition_env: BTreeMap, } /// Result of a single install step (CLI or adapter). @@ -730,7 +806,7 @@ pub struct UpdateTeamRequest { pub const DEFAULT_ACP_COMMAND: &str = "buzz-acp"; /// ~5 min (320s) — matches the CLI harness default (BUZZ_ACP_IDLE_TIMEOUT). pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320; -pub const DEFAULT_AGENT_PARALLELISM: u32 = 24; +pub const DEFAULT_AGENT_PARALLELISM: u32 = 10; fn default_agent_parallelism() -> u32 { DEFAULT_AGENT_PARALLELISM @@ -909,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 0000000000..237ffbbfe9 --- /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 0000000000..1cdb891c0a --- /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 58d60218a1..e28b0bd461 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 667a41a538..96ed556068 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/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index e83bf8b280..1e279353b6 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -22,10 +22,22 @@ const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status"; const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(60); const STATUS_PUBLISH_INTERVAL: Duration = Duration::from_secs(45); const STATUS_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10); +/// Post-launch ingress liveness / re-arm for #2062. Bounded backoff: base 15s, +/// doubles after consecutive failures up to 120s so a sticky offline peer does +/// not hammer discovery every tick, but a recovered peer is noticed quickly. +const INGRESS_WATCHDOG_BASE: Duration = Duration::from_secs(15); +const INGRESS_WATCHDOG_MAX: Duration = Duration::from_secs(120); +/// A Share Compute node may start before another member's signed status reaches +/// the relay. Recheck promptly so simultaneous starts converge into one Buzz +/// mesh instead of remaining independent islands. +const MESH_JOIN_POLL_INTERVAL: Duration = Duration::from_secs(15); +const MESH_JOIN_RETRY_MAX: Duration = Duration::from_secs(120); pub struct MeshCoordinator { _status_publisher: tokio::task::JoinHandle<()>, _roster_watcher: tokio::task::JoinHandle<()>, + _ingress_watchdog: tokio::task::JoinHandle<()>, + _mesh_join_watcher: tokio::task::JoinHandle<()>, } /// Start the runtime-owned status publisher and admission-roster watcher. @@ -56,12 +68,47 @@ pub async fn start_coordinator(app: AppHandle) { let mut pending_shrink: Option> = None; loop { tokio::time::sleep(ROSTER_POLL_INTERVAL).await; - let state = roster_app.state::(); - if let Err(error) = reconcile_roster(&state, &mut pending_shrink).await { + if let Err(error) = reconcile_roster(&roster_app, &mut pending_shrink).await { eprintln!("buzz-mesh: roster reconcile failed: {error}"); } } }); + let join_app = app.clone(); + let mesh_join_watcher = tokio::spawn(async move { + let mut sleep_for = MESH_JOIN_POLL_INTERVAL; + loop { + tokio::time::sleep(sleep_for).await; + match reconcile_buzz_mesh_join(&join_app).await { + Ok(()) => sleep_for = MESH_JOIN_POLL_INTERVAL, + Err(error) => { + eprintln!("buzz-mesh: community mesh join reconcile failed: {error}"); + sleep_for = (sleep_for * 2).min(MESH_JOIN_RETRY_MAX); + } + } + } + }); + + // Brad #2304 / #2062: ensure_relay_mesh_for_record only runs on explicit + // start + launch restore. After launch, local buzz-agent processes talk + // directly to :9337; there is no desktop "turn dispatch" hook. This + // watchdog is the post-launch seam: probe ingress, drop a zombie handle, + // re-arm via ensure_relay_mesh_for_record, surface last_error on failure. + let ingress_app = app.clone(); + let ingress_watchdog = tokio::spawn(async move { + let mut sleep_for = INGRESS_WATCHDOG_BASE; + loop { + tokio::time::sleep(sleep_for).await; + match crate::mesh_llm::rearm_relay_mesh_for_running_agents(&ingress_app).await { + Ok(()) => { + sleep_for = INGRESS_WATCHDOG_BASE; + } + Err(error) => { + eprintln!("buzz-mesh: ingress re-arm watchdog: {error}"); + sleep_for = (sleep_for * 2).min(INGRESS_WATCHDOG_MAX); + } + } + } + }); let state = app.state::(); let mut guard = state.mesh_coordinator.lock().await; @@ -69,13 +116,86 @@ pub async fn start_coordinator(app: AppHandle) { *guard = Some(MeshCoordinator { _status_publisher: status_publisher, _roster_watcher: roster_watcher, + _ingress_watchdog: ingress_watchdog, + _mesh_join_watcher: mesh_join_watcher, }); } else { status_publisher.abort(); roster_watcher.abort(); + ingress_watchdog.abort(); + mesh_join_watcher.abort(); } } +/// Join an isolated runtime to the existing Buzz community mesh. The relay is +/// discovery only: the selected endpoint is member-signed and validated, then +/// MeshLLM establishes the encrypted peer transport itself. +async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let peer_ids = { + let runtime = state.mesh_llm_runtime.lock().await; + let Some(runtime) = runtime.as_ref() else { + return Ok(()); + }; + let payload = runtime + .status_report_payload() + .await + .map_err(|error| error.to_string())?; + visible_peer_ids(&payload) + }; + + let targets = crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state).await?; + let Some(target) = targets + .into_iter() + .find(|target| !target_is_visible(target, &peer_ids)) + else { + return Ok(()); + }; + + let runtime = state.mesh_llm_runtime.lock().await; + let Some(runtime) = runtime.as_ref() else { + return Ok(()); + }; + let payload = runtime + .status_report_payload() + .await + .map_err(|error| error.to_string())?; + if target_is_visible(&target, &visible_peer_ids(&payload)) { + return Ok(()); + } + runtime + .dial_endpoint_addr(target.endpoint_addr) + .await + .map_err(|error| format!("mesh join failed: {error:#}")) +} + +fn visible_peer_ids(payload: &serde_json::Value) -> Vec { + payload + .get("peers") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|peer| peer.get("id").and_then(serde_json::Value::as_str)) + .map(|id| id.trim().to_ascii_lowercase()) + .filter(|id| !id.is_empty()) + .collect() +} + +fn target_is_visible(target: &crate::mesh_llm::MeshServeTarget, peer_ids: &[String]) -> bool { + let Some(endpoint_id) = target.endpoint_id.as_deref() else { + return false; + }; + let endpoint_id = endpoint_id.trim().to_ascii_lowercase(); + if endpoint_id.is_empty() { + return false; + } + peer_ids.iter().any(|peer_id| { + let peer_id = peer_id.trim().to_ascii_lowercase(); + !peer_id.is_empty() + && (endpoint_id.starts_with(&peer_id) || peer_id.starts_with(&endpoint_id)) + }) +} + /// Outcome of a roster reconcile decision. #[derive(Debug, PartialEq, Eq)] enum RosterReconcileAction { @@ -140,9 +260,10 @@ fn roster_reconcile_action( } async fn reconcile_roster( - state: &AppState, + app: &AppHandle, pending_shrink: &mut Option>, ) -> Result<(), String> { + let state = app.state::(); let current_request = { let runtime = state.mesh_llm_runtime.lock().await; match runtime.as_ref() { @@ -162,7 +283,7 @@ async fn reconcile_roster( // other member on a transient relay blip (the flapping restart loop). Keep // the current allowlist and try again on the next poll. A shrink is held // for one extra poll (hysteresis) so a single short-read never tears down. - let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(state).await; + let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(&state).await; let fresh = match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) { RosterReconcileAction::Keep => { *pending_shrink = None; @@ -179,19 +300,65 @@ async fn reconcile_roster( } }; - let mut request = current_request; + let mut request = current_request.clone(); request.trusted_owner_ids = Some(fresh); + // Bootstrap endpoints are live device state, not configuration. The + // endpoint used at the previous start may belong to the member that just + // left or to a device whose iroh identity rotated while offline. Resolve a + // fresh validated peer for this restart; starting isolated is safe because + // the join watcher will converge it when a member next publishes. + request.join_token = match crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state) + .await + { + Ok(targets) => targets + .into_iter() + .next() + .map(|target| target.endpoint_addr), + Err(error) => { + eprintln!( + "buzz-mesh: could not refresh bootstrap endpoint for roster restart; starting isolated: {error}" + ); + None + } + }; let mut guard = state.mesh_llm_runtime.lock().await; + let startup_pending = match guard.as_ref() { + Some(runtime) => runtime.is_starting().await, + None => false, + }; + if startup_pending { + eprintln!( + "buzz-mesh: membership roster changed while client management startup is pending; deferring restart" + ); + return Ok(()); + } + if guard + .as_ref() + .is_some_and(|runtime| runtime.start_request() != ¤t_request) + { + // The runtime changed while relay discovery was in flight. Its own + // request is now authoritative; the next poll will reconcile that + // runtime instead of tearing down a fresh replacement from this stale + // snapshot. + return Ok(()); + } let Some(running) = guard.take() else { return Ok(()); }; eprintln!("buzz-mesh: membership roster changed; restarting mesh node with fresh allowlist"); if let Err(error) = running.stop().await { - eprintln!("buzz-mesh: stopping mesh node for roster restart failed: {error}"); + drop(guard); + eprintln!( + "buzz-mesh: stopping mesh node for roster restart failed; restarting Buzz instead of racing the occupied ingress: {error}" + ); + app.request_restart(); + return Err(format!( + "mesh node shutdown failed during roster change: {error}" + )); } let replacement = crate::mesh_llm::DesktopMeshRuntime::start(request) .await - .map_err(|error| format!("mesh node restart after roster change failed: {error}"))?; + .map_err(|error| format!("mesh node restart after roster change failed: {error:#}"))?; *guard = Some(replacement); Ok(()) } @@ -317,6 +484,51 @@ mod tests { use super::*; + fn join_target(endpoint_id: Option<&str>) -> crate::mesh_llm::MeshServeTarget { + crate::mesh_llm::MeshServeTarget { + model_id: "model".to_string(), + model_name: None, + endpoint_addr: "iroh://example".to_string(), + reporter_pubkey: Some("member".to_string()), + owner_id: Some("owner".to_string()), + node_name: None, + capacity: None, + endpoint_id: endpoint_id.map(str::to_string), + device_id: None, + device_name: None, + } + } + + #[test] + fn visible_peer_ids_reads_the_sdk_status_shape() { + assert_eq!( + visible_peer_ids(&serde_json::json!({ + "peers": [{"id": " ABC123 "}, {"id": "def456"}, {"name": "ignored"}] + })), + vec!["abc123".to_string(), "def456".to_string()] + ); + } + + #[test] + fn target_visibility_accepts_sdk_short_endpoint_ids() { + let target = join_target(Some("ABC1234567890")); + + assert!(target_is_visible(&target, &["abc123".to_string()])); + assert!(!target_is_visible(&target, &["def456".to_string()])); + } + + #[test] + fn target_without_an_endpoint_id_is_never_treated_as_connected() { + assert!(!target_is_visible( + &join_target(None), + &["abc123".to_string()] + )); + assert!(!target_is_visible( + &join_target(Some(" ")), + &["abc123".to_string()] + )); + } + // Regression: a transient roster-query failure must never restart the node // down to self-only. Before the fix, `resolve_trusted_owner_ids` returned // an empty Vec on error, which `reconcile_roster` read as "roster changed diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs index d7dca6b11b..2df4a7f8c5 100644 --- a/desktop/src-tauri/src/mesh_llm/discovery.rs +++ b/desktop/src-tauri/src/mesh_llm/discovery.rs @@ -5,9 +5,10 @@ use sha2::{Digest, Sha256}; use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, MESH_STATUS_KIND}; -/// Running-node status notes are refreshed every 45 seconds. Ignore notes older -/// than two minutes so crashed/offline devices stop contributing compute or -/// admission identities without requiring a relay-side cleanup job. +/// Running-node status notes are refreshed every 45 seconds. Routing ignores +/// notes older than two minutes so crashed/offline devices stop contributing +/// compute without a relay-side cleanup job. Admission intentionally does not: +/// Buzz membership, rather than device liveness, is the trust boundary. pub(super) const STATUS_FRESHNESS_SECS: u64 = 120; pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100; @@ -199,13 +200,14 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability { let Ok(content) = serde_json::from_str::(&event.content) else { continue; }; - if owner_id_from_status_event(&event).is_none() { + let Some(owner_id) = owner_id_from_status_event(&event) else { continue; - } + }; if !endpoint_binding_is_valid(&event, &content) { continue; } saw_valid_status = true; + let reporter_pubkey = event.pubkey.to_hex().to_ascii_lowercase(); let mut serve_targets = content .get("serveTargets") .or_else(|| content.get("serve_targets")) @@ -218,6 +220,8 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability { super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr) .ok()?; target.endpoint_addr = validated.join_token; + target.reporter_pubkey = Some(reporter_pubkey.clone()); + target.owner_id = Some(owner_id.clone()); if target.endpoint_id.is_none() { target.endpoint_id = Some(validated.endpoint_id); } diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index acece498c0..6e3ab4b28b 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -22,6 +22,16 @@ pub use identity::ensure_owner_identity; mod progress; pub use progress::install_progress_sink; +mod recovery; +pub use recovery::MeshRecoveryState; +pub(crate) use recovery::{ + rearm_relay_mesh_for_running_agents, recover_stale_mesh_runtime, MeshRecoveryUrgency, + MeshRuntimeRecovery, +}; + +mod usage; +pub use usage::{serving_usage_from_payload, MeshServingUsage}; + mod transport_policy; #[cfg(test)] use transport_policy::iroh_relay_mode_from; @@ -48,6 +58,17 @@ const MESH_IROH_RELAYS_ENV: &str = "BUZZ_MESH_IROH_RELAYS"; /// First model load can include a multi-GB download plus Metal warmup; the /// SDK default (30s) times out long before that. Matches mesh-console. const MESH_STARTUP_TIMEOUT: Duration = Duration::from_secs(180); +/// The pinned SDK defines startup readiness as the management API on `:3131`. +/// Buzz defines client readiness by the OpenAI ingress agents consume on +/// `:9337`, so it supervises client startup independently and gives the SDK a +/// long management deadline. A live ingress is therefore not torn down merely +/// because the optional management API is delayed; Buzz's ingress watchdog is +/// responsible for aborting and re-arming genuinely dead attempts. +const MESH_CLIENT_MANAGEMENT_TIMEOUT: Duration = Duration::from_secs(365 * 24 * 60 * 60); +/// Bound explicit and watchdog-driven shutdowns. `EmbeddedNodeHandle::stop` +/// sends the shutdown signal before awaiting the runtime thread, so dropping +/// that wait after the deadline still leaves a graceful shutdown in flight. +const MESH_STOP_TIMEOUT: Duration = Duration::from_secs(12); /// Sentinel model id meaning "let the mesh router pick". mesh-llm's OpenAI /// ingress auto-routes `"model": "auto"` to a context-compatible live target /// (`resolve_auto_routed_model`), so agents don't have to name a model and @@ -66,6 +87,15 @@ pub struct MeshServeTarget { pub model_id: String, pub model_name: Option, pub endpoint_addr: String, + /// Buzz member that signed the discovery note containing this target. + /// Populated after signature/membership validation; never trusted from the + /// note payload itself. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reporter_pubkey: Option, + /// Per-runtime MeshLLM owner identity verified by the signed Buzz status. + /// Distinguishes two devices logged into the same Buzz member account. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_id: Option, pub node_name: Option, pub capacity: Option, #[serde(default)] @@ -166,6 +196,10 @@ pub struct StartMeshNodeRequest { pub max_vram_gb: Option, #[serde(default)] pub join_token: Option, + /// Stable, relay-scoped mesh name injected by the Buzz backend. It is not + /// accepted from the frontend and contains no relay address. + #[serde(default, skip_deserializing)] + pub mesh_name: Option, /// Mesh owner ids admitted to this node (the member roster from /// member-signed discovery notes). `None` = caller did not resolve a roster /// (tests, direct invocations): the node runs without allowlist @@ -196,80 +230,6 @@ pub struct MeshNodeStatus { pub device_name: Option, } -/// Host-side "who is using the compute I'm sharing" snapshot. -/// -/// Read-only projection of the serving node's own runtime metrics (the same -/// `routing_metrics` / `inflight_requests` the SDK already exposes on the local -/// console). No new trust surface: it reads the node's own status payload. -/// -/// The local/remote/endpoint attempt split is what distinguishes *my own* -/// agent (local) from *another member consuming my compute* (remote/endpoint). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -pub struct MeshServingUsage { - /// Requests being served right now. - pub inflight: u64, - /// Highest concurrent in-flight seen this session. - pub peak_inflight: u64, - /// Total requests routed through this node. - pub requests_served: u64, - /// Completion tokens produced. - pub tokens_served: u64, - /// Recent decode throughput. - pub tokens_per_second: f64, - /// Requests served for this machine's own agents. - pub local_attempts: u64, - /// Requests served for a remote peer (someone else consuming my compute). - pub remote_attempts: u64, - /// Requests served via an advertised endpoint (also a remote consumer). - pub endpoint_attempts: u64, - /// Other nodes currently visible as peers. - pub peers: u64, -} - -impl MeshServingUsage { - /// True when at least one request has been served for a non-local consumer. - pub fn has_remote_consumers(&self) -> bool { - self.remote_attempts > 0 || self.endpoint_attempts > 0 - } -} - -/// Pure extractor: project a raw SDK status payload into [`MeshServingUsage`]. -/// -/// Every field is read defensively (missing → 0) so an SDK shape change -/// degrades to "no usage shown" rather than an error. Kept pure so it can be -/// unit-tested against a captured payload without a live runtime. -pub fn serving_usage_from_payload(payload: &serde_json::Value) -> MeshServingUsage { - let u64_at = |v: &serde_json::Value| v.as_u64().unwrap_or(0); - let rm = payload.get("routing_metrics"); - let local = rm.and_then(|m| m.get("local_node")); - let get_u64 = |obj: Option<&serde_json::Value>, key: &str| { - obj.and_then(|o| o.get(key)).map(u64_at).unwrap_or(0) - }; - MeshServingUsage { - inflight: local - .and_then(|l| l.get("current_inflight_requests")) - .map(u64_at) - .or_else(|| payload.get("inflight_requests").map(u64_at)) - .unwrap_or(0), - peak_inflight: get_u64(local, "peak_inflight_requests"), - requests_served: get_u64(rm, "request_count"), - tokens_served: get_u64(rm, "completion_tokens_observed"), - tokens_per_second: rm - .and_then(|m| m.get("avg_tokens_per_second")) - .and_then(serde_json::Value::as_f64) - .unwrap_or(0.0), - local_attempts: get_u64(local, "local_attempt_count"), - remote_attempts: get_u64(local, "remote_attempt_count"), - endpoint_attempts: get_u64(local, "endpoint_attempt_count"), - peers: payload - .get("peers") - .and_then(serde_json::Value::as_array) - .map(|a| a.len() as u64) - .unwrap_or(0), - } -} - pub fn stopped_status() -> MeshNodeStatus { MeshNodeStatus { state: MeshNodeState::Off, @@ -286,9 +246,31 @@ pub fn stopped_status() -> MeshNodeStatus { } } +/// Monotonic id source so callers can compare runtime *identity* across an +/// `.await` point. The re-arm watchdog must not evict a fresh replacement that +/// a concurrent stop/start swapped in while the ingress probe was in flight +/// (Brad #2304 race), so it captures the id before probing and only evicts if +/// the same handle is still installed on lock reacquire. +static MESH_RUNTIME_ID_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +enum DesktopMeshHandle { + /// The pinned SDK does not return its handle until `:3131/api/status` is + /// available. Keep that wait off the agent-save path while Buzz supervises + /// the real `:9337` ingress independently. + Starting { + task: tokio::task::JoinHandle>, + queued_join_tokens: Vec, + }, + Ready(EmbeddedNodeHandle), + Failed(String), +} + pub struct DesktopMeshRuntime { - handle: EmbeddedNodeHandle, + id: u64, + handle: tokio::sync::Mutex, mode: MeshNodeMode, + api_base_url: String, + console_url: String, model_id: Option, model_name: Option, /// The request this node was started with. Kept so the coordinator can @@ -362,6 +344,8 @@ impl DesktopMeshRuntime { ensure_model_downloaded(model).await?; } } + let api_port = mesh_api_port()?; + let console_port = mesh_console_port()?; let handle = match request.mode { MeshNodeMode::Serve => { let model = model_id @@ -369,8 +353,8 @@ impl DesktopMeshRuntime { .ok_or_else(|| anyhow::anyhow!("modelId is required for serve mode"))?; let mut builder = serve::EmbeddedServeConfig::builder() .model(model) - .api_port(mesh_api_port()?) - .console_port(mesh_console_port()?) + .api_port(api_port) + .console_port(console_port) // No-leak invariants: never publish mesh presence, never // auto-discover other meshes, no public Nostr relays. // Iroh relays are transport-only and enabled by default @@ -380,6 +364,9 @@ impl DesktopMeshRuntime { .discovery_mode(MeshDiscoveryMode::Nostr) .startup_timeout(MESH_STARTUP_TIMEOUT) .console_ui(true); + if let Some(mesh_name) = request.mesh_name.as_deref() { + builder = builder.mesh_name(mesh_name); + } builder = match iroh_relay_mode()? { IrohRelayMode::Disabled => builder.disable_iroh_relays(true), IrohRelayMode::Default => builder.disable_iroh_relays(false), @@ -405,18 +392,21 @@ impl DesktopMeshRuntime { .trust_policy(TrustPolicy::Allowlist) .trust_owners(owners); } - serve::start(builder.build()).await? + DesktopMeshHandle::Ready(serve::start(builder.build()).await?) } MeshNodeMode::Client => { let mut builder = client::EmbeddedClientConfig::builder() - .api_port(mesh_api_port()?) - .console_port(mesh_console_port()?) + .api_port(api_port) + .console_port(console_port) // Same no-leak invariants as serve mode above. .publish(false) .auto_join(false) .discovery_mode(MeshDiscoveryMode::Nostr) - .startup_timeout(MESH_STARTUP_TIMEOUT) + .startup_timeout(MESH_CLIENT_MANAGEMENT_TIMEOUT) .console_ui(true); + if let Some(mesh_name) = request.mesh_name.as_deref() { + builder = builder.mesh_name(mesh_name); + } builder = match iroh_relay_mode()? { IrohRelayMode::Disabled => builder.disable_iroh_relays(true), IrohRelayMode::Default => builder.disable_iroh_relays(false), @@ -437,19 +427,33 @@ impl DesktopMeshRuntime { .trust_policy(TrustPolicy::Allowlist) .trust_owners(owners); } - client::start(builder.build()).await? + let config = builder.build(); + DesktopMeshHandle::Starting { + task: tokio::spawn(async move { client::start(config).await }), + queued_join_tokens: Vec::new(), + } } }; Ok(Self { - handle, + id: MESH_RUNTIME_ID_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + handle: tokio::sync::Mutex::new(handle), mode: request.mode, + api_base_url: format!("http://127.0.0.1:{api_port}/v1"), + console_url: format!("http://127.0.0.1:{console_port}"), model_id, model_name, start_request: request, }) } + /// Process-unique identity for this runtime instance. Used by the re-arm + /// watchdog to detect a concurrent handle swap across the ingress probe + /// `.await` so it never evicts a fresh replacement runtime (Brad #2304). + pub fn id(&self) -> u64 { + self.id + } + /// The request this node was started with (roster drift detection). pub fn start_request(&self) -> &StartMeshNodeRequest { &self.start_request @@ -463,13 +467,117 @@ impl DesktopMeshRuntime { self.mode } + async fn promote_finished_startup(handle: &mut DesktopMeshHandle) { + let startup_finished = matches!( + handle, + DesktopMeshHandle::Starting { task, .. } if task.is_finished() + ); + if !startup_finished { + return; + } + + let previous = std::mem::replace( + handle, + DesktopMeshHandle::Failed("mesh client startup state was unavailable".to_string()), + ); + let (task, queued_join_tokens) = match previous { + DesktopMeshHandle::Starting { + task, + queued_join_tokens, + } => (task, queued_join_tokens), + other => { + *handle = other; + return; + } + }; + *handle = match task.await { + Ok(Ok(ready)) => { + for token in queued_join_tokens { + if let Err(error) = ready.join_token(token).await { + eprintln!( + "buzz-mesh: failed to apply a dial request queued during startup: {error:#}" + ); + } + } + DesktopMeshHandle::Ready(ready) + } + Ok(Err(error)) => { + DesktopMeshHandle::Failed(format!("embedded mesh client startup failed: {error:#}")) + } + Err(error) if error.is_cancelled() => { + DesktopMeshHandle::Failed("embedded mesh client startup was cancelled".to_string()) + } + Err(error) => DesktopMeshHandle::Failed(format!( + "embedded mesh client startup task failed: {error}" + )), + }; + } + + pub async fn is_starting(&self) -> bool { + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + matches!(*handle, DesktopMeshHandle::Starting { .. }) + } + pub async fn status(&self) -> anyhow::Result { - let status = self.handle.status().await?; - self.status_from_sdk(status) + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + match &*handle { + DesktopMeshHandle::Ready(ready) => { + let status = ready.status().await; + drop(handle); + match status { + Ok(status) => self.status_from_sdk(status), + Err(error) + if self.mode == MeshNodeMode::Client + && recovery::mesh_ingress_is_live_at(&self.api_base_url).await => + { + Ok(self.ingress_only_client_status(&format!( + "management status is unavailable: {error:#}" + ))) + } + Err(error) => Err(error), + } + } + DesktopMeshHandle::Starting { .. } => { + drop(handle); + if recovery::mesh_ingress_is_live_at(&self.api_base_url).await { + Ok(self.ingress_only_client_status("management status is still starting")) + } else { + Ok(self.starting_client_status()) + } + } + DesktopMeshHandle::Failed(error) => { + let error = error.clone(); + Err(anyhow::anyhow!(error)) + } + } } pub async fn status_report_payload(&self) -> anyhow::Result { - let status = self.handle.status().await?; + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + let ready = match &*handle { + DesktopMeshHandle::Ready(ready) => ready, + DesktopMeshHandle::Starting { .. } if self.mode == MeshNodeMode::Client => { + // Consumer identities must keep publishing owner bindings while + // the SDK is still waiting for its management API. Serving + // peers derive their admission roster from these fresh member + // notes; withholding heartbeats here would make an otherwise + // working `:9337` client age out of the host's allowlist. + return Ok(serde_json::json!({ + "serveTargets": [], + "models": [], + })); + } + DesktopMeshHandle::Starting { .. } => { + anyhow::bail!("mesh management status is not ready") + } + DesktopMeshHandle::Failed(error) => { + anyhow::bail!("mesh runtime failed: {error}") + } + }; + let status = ready.status().await?; let mut payload = status.payload; enrich_status_payload_identity(&mut payload, status.invite_token.as_deref()); if let Ok(identity) = ensure_owner_identity() { @@ -485,6 +593,8 @@ impl DesktopMeshRuntime { model_id: model.id, model_name: model.name, endpoint_addr: endpoint_addr.clone(), + reporter_pubkey: None, + owner_id: None, node_name: payload .get("node_id") .and_then(serde_json::Value::as_str) @@ -504,18 +614,43 @@ impl DesktopMeshRuntime { /// Read-only host-side usage snapshot from the node's own runtime metrics. pub async fn serving_usage(&self) -> anyhow::Result { - let status = self.handle.status().await?; + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + let DesktopMeshHandle::Ready(ready) = &*handle else { + anyhow::bail!("mesh management status is not ready"); + }; + let status = ready.status().await?; Ok(serving_usage_from_payload(&status.payload)) } pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into) -> anyhow::Result<()> { let endpoint_addr = endpoint_addr.into(); let validated = validate_advertised_endpoint(&endpoint_addr)?; - self.handle.join_token(validated.join_token).await + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + match &mut *handle { + DesktopMeshHandle::Ready(ready) => ready.join_token(validated.join_token).await, + DesktopMeshHandle::Starting { + queued_join_tokens, .. + } => { + if !queued_join_tokens.contains(&validated.join_token) { + queued_join_tokens.push(validated.join_token); + } + Ok(()) + } + DesktopMeshHandle::Failed(error) => { + anyhow::bail!("cannot dial from failed mesh client: {error}") + } + } } pub async fn installed_models(&self) -> anyhow::Result> { - let status = self.handle.status().await?; + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + let DesktopMeshHandle::Ready(ready) = &*handle else { + anyhow::bail!("mesh management status is not ready"); + }; + let status = ready.status().await?; Ok(models_from_status_payload(Some(&status.payload))) } @@ -543,8 +678,60 @@ impl DesktopMeshRuntime { }) } + fn ingress_only_client_status(&self, reason: &str) -> MeshNodeStatus { + MeshNodeStatus { + state: MeshNodeState::Running, + mode: Some(MeshNodeMode::Client), + health: MeshHealth::degraded(format!("OpenAI ingress is live; {reason}")), + api_base_url: Some(self.api_base_url.clone()), + console_url: Some(self.console_url.clone()), + model_id: self.model_id.clone(), + model_name: self.model_name.clone(), + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + } + } + + fn starting_client_status(&self) -> MeshNodeStatus { + MeshNodeStatus { + state: MeshNodeState::Starting, + mode: Some(MeshNodeMode::Client), + health: MeshHealth::degraded("OpenAI ingress and management status are still starting"), + api_base_url: Some(self.api_base_url.clone()), + console_url: Some(self.console_url.clone()), + model_id: self.model_id.clone(), + model_name: self.model_name.clone(), + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + } + } + pub async fn stop(self) -> anyhow::Result<()> { - self.handle.stop().await + match self.handle.into_inner() { + DesktopMeshHandle::Ready(ready) => { + match tokio::time::timeout(MESH_STOP_TIMEOUT, ready.stop()).await { + Ok(result) => result, + Err(_) => anyhow::bail!( + "timed out after {}s waiting for embedded mesh runtime to stop", + MESH_STOP_TIMEOUT.as_secs() + ), + } + } + DesktopMeshHandle::Starting { task, .. } => { + // The pinned SDK has not exposed its shutdown handle yet. + // Abort only the Buzz-side waiter; normal callers never replace + // a pending runtime, and app shutdown/restart then terminates + // the process-owned embedded thread. Recovery requests that + // controlled restart instead of racing a second runtime. + task.abort(); + Ok(()) + } + DesktopMeshHandle::Failed(_) => Ok(()), + } } } diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index a8b78bc190..0b726c264f 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -3,6 +3,72 @@ use super::find_progressish_reason; use serde_json::json; +fn pending_client_runtime( + task: tokio::task::JoinHandle>, +) -> super::DesktopMeshRuntime { + let request = super::StartMeshNodeRequest { + mode: super::MeshNodeMode::Client, + model_id: None, + max_vram_gb: None, + join_token: Some("initial-token".to_string()), + mesh_name: None, + trusted_owner_ids: None, + }; + super::DesktopMeshRuntime { + id: 7, + handle: tokio::sync::Mutex::new(super::DesktopMeshHandle::Starting { + task, + queued_join_tokens: Vec::new(), + }), + mode: super::MeshNodeMode::Client, + api_base_url: "http://127.0.0.1:1/v1".to_string(), + console_url: "http://127.0.0.1:2".to_string(), + model_id: None, + model_name: None, + start_request: request, + } +} + +#[tokio::test] +async fn pending_client_status_does_not_wait_for_management_timeout() { + let task = tokio::spawn(async { + std::future::pending::>().await + }); + let runtime = pending_client_runtime(task); + + let status = tokio::time::timeout(std::time::Duration::from_secs(1), runtime.status()) + .await + .expect("status should not wait for the SDK management probe") + .expect("pending client should have a synthetic status"); + assert_eq!(status.state, super::MeshNodeState::Starting); + assert_eq!(status.mode, Some(super::MeshNodeMode::Client)); + let report = runtime + .status_report_payload() + .await + .expect("pending clients must keep publishing admission identity heartbeats"); + assert_eq!(report["serveTargets"], json!([])); + assert_eq!(report["models"], json!([])); + + tokio::time::timeout(std::time::Duration::from_secs(1), runtime.stop()) + .await + .expect("stopping a pending client should abort its SDK task") + .expect("pending client stop should succeed"); +} + +#[tokio::test] +async fn failed_client_startup_is_promoted_without_losing_the_runtime_slot() { + let task = tokio::spawn(async { anyhow::bail!("controlled startup failure") }); + let runtime = pending_client_runtime(task); + tokio::task::yield_now().await; + + let error = runtime + .status() + .await + .expect_err("finished failed startup should be surfaced"); + assert!(error.to_string().contains("controlled startup failure")); + assert!(!runtime.is_starting().await); +} + #[test] fn progressish_reads_typed_phase_not_whole_tree() { assert_eq!( @@ -627,10 +693,7 @@ fn serving_usage_extracts_local_and_remote_attempts() { assert_eq!(usage.local_attempts, 4); assert_eq!(usage.remote_attempts, 0); assert_eq!(usage.endpoint_attempts, 0); - assert!( - !usage.has_remote_consumers(), - "all-local traffic is not a remote consumer" - ); + assert_eq!(usage.remote_attempts + usage.endpoint_attempts, 0); } #[test] @@ -650,10 +713,7 @@ fn serving_usage_flags_remote_consumer() { assert_eq!(usage.remote_attempts, 6); assert_eq!(usage.endpoint_attempts, 1); assert_eq!(usage.peers, 2); - assert!( - usage.has_remote_consumers(), - "remote/endpoint attempts mean someone else is using my compute" - ); + assert!(usage.remote_attempts + usage.endpoint_attempts > 0); } #[test] @@ -661,5 +721,5 @@ fn serving_usage_defaults_to_zero_on_missing_fields() { // SDK shape drift must degrade to "no usage" not panic. let usage = super::serving_usage_from_payload(&json!({})); assert_eq!(usage, super::MeshServingUsage::default()); - assert!(!usage.has_remote_consumers()); + assert_eq!(usage.remote_attempts + usage.endpoint_attempts, 0); } diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs new file mode 100644 index 0000000000..809fab8993 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -0,0 +1,534 @@ +use std::collections::HashSet; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::Duration; + +use tauri::{AppHandle, Manager}; + +use crate::app_state::AppState; + +const INGRESS_PROBE_TIMEOUT: Duration = Duration::from_secs(3); +const INGRESS_CONNECT_TIMEOUT: Duration = Duration::from_millis(300); +const INGRESS_RELEASE_TIMEOUT: Duration = Duration::from_secs(3); +const STALE_STOP_TIMEOUT: Duration = Duration::from_secs(12); +const DEAD_PROBE_EVICT_THRESHOLD: u32 = 2; + +/// Sentinel prefix on errors owned by this recovery path. Recovery clears only +/// these errors and never an unrelated agent failure. +pub(crate) const MESH_REARM_ERROR_SENTINEL: &str = "[buzz-mesh-rearm] "; + +/// App-scoped recovery coordination. The runtime id binds a dead-probe streak +/// to one specific handle, while `rearm_lock` prevents overlapping watchdog +/// passes from starting competing replacements. +pub struct MeshRecoveryState { + probe_runtime_id: AtomicU64, + dead_probes: AtomicU32, + rearm_lock: tokio::sync::Mutex<()>, +} + +impl Default for MeshRecoveryState { + fn default() -> Self { + Self { + probe_runtime_id: AtomicU64::new(0), + dead_probes: AtomicU32::new(0), + rearm_lock: tokio::sync::Mutex::new(()), + } + } +} + +impl MeshRecoveryState { + fn reset_probe_streak(&self) { + self.probe_runtime_id.store(0, Ordering::Relaxed); + self.dead_probes.store(0, Ordering::Relaxed); + } + + fn record_dead_probe(&self, runtime_id: u64) -> u32 { + if self.probe_runtime_id.swap(runtime_id, Ordering::Relaxed) != runtime_id { + self.dead_probes.store(1, Ordering::Relaxed); + return 1; + } + self.dead_probes.fetch_add(1, Ordering::Relaxed) + 1 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MeshIngressProbe { + Live, + PortClosed, + Unhealthy, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MeshRecoveryUrgency { + Foreground, + Watchdog, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MeshRuntimeRecovery { + Absent, + Live, + Debouncing, + Evicted, + ReleasePending, + Replaced, + RestartRequired, +} + +/// Probe the contract agents actually consume. A successful `/v1/models` +/// response proves the local OpenAI ingress is accepting requests; the +/// management console on `:3131` is intentionally not part of this health +/// decision. +pub(crate) async fn probe_mesh_ingress() -> MeshIngressProbe { + probe_mesh_ingress_at(crate::managed_agents::RELAY_MESH_API_BASE_URL).await +} + +pub(crate) async fn mesh_ingress_is_live_at(api_base_url: &str) -> bool { + probe_mesh_ingress_at(api_base_url).await == MeshIngressProbe::Live +} + +pub(crate) async fn probe_mesh_ingress_at(api_base_url: &str) -> MeshIngressProbe { + let base = api_base_url.trim_end_matches('/'); + let client = match reqwest::Client::builder() + .timeout(INGRESS_PROBE_TIMEOUT) + .build() + { + Ok(client) => client, + Err(_) => return MeshIngressProbe::Unhealthy, + }; + if client + .get(format!("{base}/models")) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + return MeshIngressProbe::Live; + } + if ingress_port_is_bound(api_base_url).await { + MeshIngressProbe::Unhealthy + } else { + MeshIngressProbe::PortClosed + } +} + +async fn ingress_port_is_bound(api_base_url: &str) -> bool { + let Ok(url) = url::Url::parse(api_base_url) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + let Some(port) = url.port_or_known_default() else { + return false; + }; + matches!( + tokio::time::timeout( + INGRESS_CONNECT_TIMEOUT, + tokio::net::TcpStream::connect((host, port)), + ) + .await, + Ok(Ok(_)) + ) +} + +async fn wait_for_ingress_release() -> bool { + let deadline = tokio::time::Instant::now() + INGRESS_RELEASE_TIMEOUT; + loop { + if !ingress_port_is_bound(crate::managed_agents::RELAY_MESH_API_BASE_URL).await { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn should_evict_after_probe( + urgency: MeshRecoveryUrgency, + probe: MeshIngressProbe, + consecutive: u32, +) -> bool { + urgency == MeshRecoveryUrgency::Foreground && probe == MeshIngressProbe::PortClosed + || consecutive >= DEAD_PROBE_EVICT_THRESHOLD +} + +/// Probe and, when justified, remove one stale runtime. A closed port is +/// decisive for a foreground agent start; watchdog and ambiguous/unhealthy +/// ports require consecutive failures to avoid restarting on a transient load +/// spike. +pub(crate) async fn recover_stale_mesh_runtime( + state: &AppState, + urgency: MeshRecoveryUrgency, +) -> MeshRuntimeRecovery { + let (candidate_id, startup_in_progress) = match state.mesh_llm_runtime.lock().await.as_ref() { + Some(runtime) => (runtime.id(), runtime.is_starting().await), + None => { + state.mesh_recovery.reset_probe_streak(); + // A cancelled SDK startup can outlive its Buzz-side task briefly + // because the embedded runtime runs on its own thread. Never start + // a replacement merely because the tracked handle is gone: first + // prove the old ingress is either still useful or has released the + // port. This closes the port-conflict loop in #2304. + return match probe_mesh_ingress().await { + MeshIngressProbe::Live => MeshRuntimeRecovery::Live, + MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, + MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, + }; + } + }; + let probe = probe_mesh_ingress().await; + if probe == MeshIngressProbe::Live { + state.mesh_recovery.reset_probe_streak(); + return MeshRuntimeRecovery::Live; + } + let consecutive = state.mesh_recovery.record_dead_probe(candidate_id); + if startup_in_progress && consecutive < DEAD_PROBE_EVICT_THRESHOLD { + eprintln!( + "buzz-mesh: ingress is not live on the first startup probe; allowing the supervised SDK task one watchdog interval" + ); + return MeshRuntimeRecovery::Debouncing; + } + if !should_evict_after_probe(urgency, probe, consecutive) { + eprintln!( + "buzz-mesh: ingress probe {probe:?} ({consecutive}/{DEAD_PROBE_EVICT_THRESHOLD}); debouncing" + ); + return MeshRuntimeRecovery::Debouncing; + } + + // The pinned SDK does not yield its control handle until the management + // API is ready. Dropping its still-pending start future would detach the + // embedded runtime thread without sending a shutdown request, so Buzz must + // not evict it and race a replacement onto the same ports. A controlled + // app relaunch is the only process-owned cleanup boundary in this state. + if startup_in_progress { + state.mesh_recovery.reset_probe_streak(); + return MeshRuntimeRecovery::RestartRequired; + } + + let stale = { + let mut guard = state.mesh_llm_runtime.lock().await; + if guard.as_ref().map(|runtime| runtime.id()) != Some(candidate_id) { + state.mesh_recovery.reset_probe_streak(); + return MeshRuntimeRecovery::Replaced; + } + guard.take() + }; + let Some(stale) = stale else { + return MeshRuntimeRecovery::Replaced; + }; + state.mesh_recovery.reset_probe_streak(); + + match tokio::time::timeout(STALE_STOP_TIMEOUT, stale.stop()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => eprintln!("buzz-mesh: stale runtime stop failed: {error:#}"), + Err(_) => eprintln!( + "buzz-mesh: stale runtime stop exceeded {}s; waiting for port release", + STALE_STOP_TIMEOUT.as_secs() + ), + } + if wait_for_ingress_release().await { + MeshRuntimeRecovery::Evicted + } else { + eprintln!( + "buzz-mesh: old runtime still owns the local ingress; deferring replacement to avoid a port-conflict loop" + ); + MeshRuntimeRecovery::ReleasePending + } +} + +/// Post-launch recovery for actively running relay-mesh agents. +pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let _rearm_guard = state.mesh_recovery.rearm_lock.lock().await; + let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await; + let active_pubkeys = active_managed_agent_pubkeys(&state); + // Mesh participation is resolved through the same definition-authoritative + // path as spawn/restore (#1968): definition → global fallback. A linked + // instance's own bytes never contribute. + let personas = crate::managed_agents::load_personas(app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + + match recovery { + MeshRuntimeRecovery::Live + | MeshRuntimeRecovery::Debouncing + | MeshRuntimeRecovery::Replaced => return Ok(()), + MeshRuntimeRecovery::RestartRequired => { + let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); + if !records.iter().any(|record| { + running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() + }) { + // A foreground save may still be bringing up its first ingress. + // Only an already-running consumer justifies an automatic app + // relaunch from the background watchdog. + return Ok(()); + } + eprintln!( + "buzz-mesh: supervised client startup lost its ingress before the SDK exposed a shutdown handle; restarting Buzz" + ); + app.request_restart(); + return Ok(()); + } + MeshRuntimeRecovery::ReleasePending => { + return Err(format!( + "{MESH_REARM_ERROR_SENTINEL}old local mesh ingress is still shutting down" + )); + } + MeshRuntimeRecovery::Absent => { + let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); + if !records.iter().any(|record| { + running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() + }) { + return Ok(()); + } + } + MeshRuntimeRecovery::Evicted => {} + } + + let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); + let mesh_records: Vec<_> = records + .into_iter() + .filter_map(|record| { + running_relay_mesh_model_id(&record, &active_pubkeys, &personas, &global) + .map(|mesh_model_id| (record, mesh_model_id)) + }) + .collect(); + let mut first_error = None; + for (record, mesh_model_id) in &mesh_records { + match crate::commands::mesh_llm::ensure_relay_mesh_for_record( + app, + Some(mesh_model_id.as_str()), + false, + ) + .await + { + Ok(()) => { + if let Err(error) = clear_mesh_last_error_if_set(app, &record.pubkey) { + eprintln!("buzz-mesh: failed to clear recovery error: {error}"); + } + } + Err(error) => { + let message = format!( + "{MESH_REARM_ERROR_SENTINEL}Buzz shared compute offline — failed to re-arm local ingress for this agent: {error}" + ); + if let Err(persist_error) = persist_mesh_last_error(app, &record.pubkey, &message) { + eprintln!("buzz-mesh: failed to persist recovery error: {persist_error}"); + } + first_error.get_or_insert(message); + } + } + } + first_error.map_or(Ok(()), Err) +} + +fn active_managed_agent_pubkeys(state: &AppState) -> HashSet { + state + .managed_agent_processes + .lock() + .map(|guard| { + guard + .keys() + .map(|key| key.pubkey.to_ascii_lowercase()) + .collect() + }) + .unwrap_or_default() +} + +/// Effective mesh model for a record that is actively running, or `None` +/// when the record is not a running relay-mesh consumer. Resolution goes +/// through `resolve_effective_relay_mesh_model_id` (definition → global +/// fallback, #1968) so the watchdog agrees with spawn/restore about which +/// agents are mesh-backed. +fn running_relay_mesh_model_id( + record: &crate::managed_agents::ManagedAgentRecord, + active_pubkeys: &HashSet, + personas: &[crate::managed_agents::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Option { + let running = record.backend == crate::managed_agents::BackendKind::Local + && active_pubkeys.contains(&record.pubkey.to_ascii_lowercase()) + && record + .runtime_pid + .is_none_or(crate::managed_agents::process_is_running); + if !running { + return None; + } + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + record, personas, global, + ) +} + +fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire managed agents store lock: {e}"))?; + let mut records = crate::managed_agents::load_managed_agents(app)?; + let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; + record.last_error = Some(error.to_string()); + record.updated_at = crate::util::now_iso(); + crate::managed_agents::save_managed_agents(app, &records) +} + +fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire managed agents store lock: {e}"))?; + let mut records = crate::managed_agents::load_managed_agents(app)?; + let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; + if !record + .last_error + .as_deref() + .is_some_and(|error| error.starts_with(MESH_REARM_ERROR_SENTINEL)) + { + return Ok(()); + } + record.last_error = None; + record.updated_at = crate::util::now_iso(); + crate::managed_agents::save_managed_agents(app, &records) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mesh_record( + pubkey: &str, + runtime_pid: Option, + ) -> crate::managed_agents::ManagedAgentRecord { + let mut record = crate::managed_agents::AgentDefinition { + id: pubkey.to_string(), + display_name: pubkey.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + 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()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1/".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + .into_agent_record(); + record.pubkey = pubkey.to_string(); + record.backend = crate::managed_agents::BackendKind::Local; + record.runtime_pid = runtime_pid; + record + } + + fn active_set(pubkeys: &[&str]) -> HashSet { + pubkeys + .iter() + .map(|pubkey| pubkey.to_ascii_lowercase()) + .collect() + } + + #[tokio::test] + async fn closed_port_is_distinct_from_unhealthy_bound_port() { + assert_eq!( + probe_mesh_ingress_at("http://127.0.0.1:1/v1").await, + MeshIngressProbe::PortClosed + ); + } + + #[test] + fn foreground_closed_port_evicts_without_debounce() { + assert!(should_evict_after_probe( + MeshRecoveryUrgency::Foreground, + MeshIngressProbe::PortClosed, + 1 + )); + assert!(!should_evict_after_probe( + MeshRecoveryUrgency::Watchdog, + MeshIngressProbe::PortClosed, + 1 + )); + } + + #[test] + fn probe_streak_is_scoped_to_runtime_identity() { + let state = MeshRecoveryState::default(); + assert_eq!(state.record_dead_probe(7), 1); + assert_eq!(state.record_dead_probe(7), 2); + assert_eq!(state.record_dead_probe(8), 1); + } + + #[test] + fn explicit_stop_budget_accommodates_sdk_forced_shutdown() { + assert!(STALE_STOP_TIMEOUT >= Duration::from_secs(10)); + assert!(STALE_STOP_TIMEOUT <= Duration::from_secs(15)); + } + + #[test] + fn only_running_relay_mesh_agents_trigger_rearm() { + let personas: Vec = Vec::new(); + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let empty = active_set(&[]); + assert!(running_relay_mesh_model_id( + &mesh_record("stopped", Some(std::process::id())), + &empty, + &personas, + &global, + ) + .is_none()); + + let active = active_set(&["live"]); + assert_eq!( + running_relay_mesh_model_id( + &mesh_record("live", Some(std::process::id())), + &active, + &personas, + &global, + ) + .as_deref(), + Some("Qwen3") + ); + assert_eq!( + running_relay_mesh_model_id(&mesh_record("live", None), &active, &personas, &global) + .as_deref(), + Some("Qwen3") + ); + + let mut non_mesh = mesh_record("plain", Some(std::process::id())); + non_mesh.env_vars.clear(); + non_mesh.relay_mesh = None; + assert!(running_relay_mesh_model_id( + &non_mesh, + &active_set(&["plain"]), + &personas, + &global, + ) + .is_none()); + } + + #[test] + fn recovery_error_sentinel_does_not_match_unrelated_errors() { + assert!( + format!("{MESH_REARM_ERROR_SENTINEL}offline").starts_with(MESH_REARM_ERROR_SENTINEL) + ); + assert!(!"user note: shared compute config".starts_with(MESH_REARM_ERROR_SENTINEL)); + } +} diff --git a/desktop/src-tauri/src/mesh_llm/usage.rs b/desktop/src-tauri/src/mesh_llm/usage.rs new file mode 100644 index 0000000000..830f224ef5 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/usage.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; + +/// Host-side "who is using the compute I'm sharing" snapshot. +/// +/// Read-only projection of the serving node's own runtime metrics (the same +/// `routing_metrics` / `inflight_requests` the SDK already exposes on the local +/// console). No new trust surface: it reads the node's own status payload. +/// +/// The local/remote/endpoint attempt split is what distinguishes *my own* +/// agent (local) from *another member consuming my compute* (remote/endpoint). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct MeshServingUsage { + /// Requests being served right now. + pub inflight: u64, + /// Highest concurrent in-flight seen this session. + pub peak_inflight: u64, + /// Total requests routed through this node. + pub requests_served: u64, + /// Completion tokens produced. + pub tokens_served: u64, + /// Recent decode throughput. + pub tokens_per_second: f64, + /// Requests served for this machine's own agents. + pub local_attempts: u64, + /// Requests served for a remote peer (someone else consuming my compute). + pub remote_attempts: u64, + /// Requests served via an advertised endpoint (also a remote consumer). + pub endpoint_attempts: u64, + /// Other nodes currently visible as peers. + pub peers: u64, +} + +/// Pure extractor: project a raw SDK status payload into [`MeshServingUsage`]. +/// +/// Every field is read defensively (missing → 0) so an SDK shape change +/// degrades to "no usage shown" rather than an error. Kept pure so it can be +/// unit-tested against a captured payload without a live runtime. +pub fn serving_usage_from_payload(payload: &serde_json::Value) -> MeshServingUsage { + let u64_at = |v: &serde_json::Value| v.as_u64().unwrap_or(0); + let rm = payload.get("routing_metrics"); + let local = rm.and_then(|m| m.get("local_node")); + let get_u64 = |obj: Option<&serde_json::Value>, key: &str| { + obj.and_then(|o| o.get(key)).map(u64_at).unwrap_or(0) + }; + MeshServingUsage { + inflight: local + .and_then(|l| l.get("current_inflight_requests")) + .map(u64_at) + .or_else(|| payload.get("inflight_requests").map(u64_at)) + .unwrap_or(0), + peak_inflight: get_u64(local, "peak_inflight_requests"), + requests_served: get_u64(rm, "request_count"), + tokens_served: get_u64(rm, "completion_tokens_observed"), + tokens_per_second: rm + .and_then(|m| m.get("avg_tokens_per_second")) + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0), + local_attempts: get_u64(local, "local_attempt_count"), + remote_attempts: get_u64(local, "remote_attempt_count"), + endpoint_attempts: get_u64(local, "endpoint_attempt_count"), + peers: payload + .get("peers") + .and_then(serde_json::Value::as_array) + .map(|a| a.len() as u64) + .unwrap_or(0), + } +} diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 0de8ea0a82..b3e613621e 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -176,6 +176,11 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { // Post-fold readers of the runtime map (`load_persona_runtimes`) fall // back to the unified store's definitions. fold_personas_into_agent_store(app); + // Clean the legacy baked team-instructions suffix out of stored prompts + // AFTER the fold (so definitions lifted out of personas.json are cleaned in + // the same boot) and BEFORE backfill_standalone_agents (so a manufactured + // definition never snapshots a suffix this strips). + strip_baked_team_instructions(app); refresh_builtin_agent_avatars(app); // B5: manufacture definitions for standalone agents AFTER the fold (so // pre-existing definition slugs are present for collision checks) and @@ -1362,7 +1367,6 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { } rename_provider_to_runtime_in_personas(&path); } - mod materialize; pub use materialize::materialize_agent_runtimes; mod fold; @@ -1372,6 +1376,8 @@ mod backfill; pub use backfill::backfill_standalone_agents; mod detach; pub use detach::detach_directory_backed_teams; +mod team_suffix; +pub use team_suffix::strip_baked_team_instructions; #[cfg(test)] #[path = "migration_test_support.rs"] diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs index b7f85df138..cd62f63bbb 100644 --- a/desktop/src-tauri/src/migration/backfill.rs +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -65,12 +65,13 @@ fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { } // Pre-migration backup, taken ONCE: a re-run after a partial failure must - // not overwrite the pristine backup with a half-migrated snapshot. - let bak_path = base_dir.join("managed-agents.json.pre-backfill.bak"); - if !bak_path.exists() { - std::fs::write(&bak_path, &content) - .map_err(|e| format!("failed to write pre-backfill backup: {e}"))?; - } + // not overwrite the pristine backup with a half-migrated snapshot. Owner-only + // from the initial open, and sited next to the resolved store — see + // `create_restricted_backup_once` and `resolved_backup_path`. + let bak_path = + crate::util::resolved_backup_path(&agents_path, "managed-agents.json.pre-backfill.bak"); + crate::util::create_restricted_backup_once(&bak_path, content.as_bytes()) + .map_err(|e| format!("failed to write pre-backfill backup: {e}"))?; let existing_slugs: std::collections::HashSet = all.iter().filter_map(|r| r.slug.clone()).collect(); diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index 9d2731d241..5d52d56678 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -299,3 +299,34 @@ fn slug_collision_fails_loudly_per_record_and_continues() { let clean_rec = records.iter().find(|r| r.pubkey == clean).unwrap(); assert_eq!(clean_rec.persona_id.as_deref(), Some(clean.as_str())); } + +#[cfg(unix)] +#[test] +fn backfill_creates_the_backup_owner_only() { + // Same contract as the live store writer: this backup is a verbatim copy of + // a file that carries plaintext agent nsecs when the keyring is unreachable, + // so it is owner-only from the initial open rather than via a post-write + // chmod that would leave a umask window. + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let pubkey = "e".repeat(64); + let mut record = standalone_agent_json("Solo", &pubkey, Some("P")); + record["private_key_nsec"] = serde_json::json!("nsec1exampleplaintextkey"); + write_agents_json(dir.path(), &serde_json::json!([record])); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + + let bak = base(dir.path()).join("managed-agents.json.pre-backfill.bak"); + let mode = std::fs::metadata(&bak).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "backup of an inline nsec must be owner-only"); + assert!( + std::fs::read_to_string(&bak) + .unwrap() + .contains("nsec1exampleplaintextkey"), + "the fixture really did carry an inline key" + ); +} diff --git a/desktop/src-tauri/src/migration/team_suffix.rs b/desktop/src-tauri/src/migration/team_suffix.rs new file mode 100644 index 0000000000..d65113fb83 --- /dev/null +++ b/desktop/src-tauri/src/migration/team_suffix.rs @@ -0,0 +1,182 @@ +//! Strip the legacy baked team-instructions suffix from stored prompts. +//! +//! Records written before the runtime team framing landed have their team +//! instructions BAKED into `system_prompt` by the now-removed +//! `compose_prompt()` in buzz-persona: +//! +//! ```text +//! {persona_prompt}\n\n---\n# Team Instructions\n{instructions} +//! ``` +//! +//! `with_team()` in `buzz-acp/src/pool.rs` now appends the LIVE +//! `[Team Instructions]` section on top of that stored value, so an affected +//! agent receives two team-instruction blocks per turn — the frozen copy first, +//! the live one second — and the observer feed renders two Team Instructions +//! cards. The frozen copy is not merely redundant: it carries whatever the team +//! roster said the day it was written, so the agent is fed a stale roster ahead +//! of the current one. +//! +//! The fix has to happen at rest. Suppressing the duplicate in the observer +//! parser would hide the symptom while the agent kept receiving the stale bytes. +//! +//! Stripping a definition's prompt also changes its `persona_content_hash`, the +//! drift basis behind the Agents-menu "out of date" badge, so the migration +//! advances the pin of instances that were current before the strip (see +//! [`repin_current_instances`]). + +use std::collections::HashMap; +use std::path::Path; + +use crate::managed_agents::{ + persona_events::{persona_content_hash, persona_event_content}, + ManagedAgentRecord, +}; + +/// The exact producer boundary emitted by the removed `compose_prompt()`. +/// Matched byte-for-byte: a bare `---`, a `# Team Instructions` heading at a +/// different position, or a single preceding newline are author content, not a +/// producer boundary, and are left alone. +const TEAM_DELIMITER: &str = "\n\n---\n# Team Instructions\n"; + +/// Strip the baked team-instructions suffix from every stored `system_prompt`. +/// +/// Ordering (see `run_boot_migrations`): runs AFTER +/// `fold_personas_into_agent_store` so definitions folded out of the legacy +/// `personas.json` are cleaned in the same boot, and BEFORE +/// `backfill_standalone_agents` so a manufactured definition never snapshots +/// a suffix this migration is about to remove. +pub fn strip_baked_team_instructions(app: &tauri::AppHandle) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + match strip_baked_team_instructions_in_dir(&base_dir) { + Ok(0) => {} + Ok(stripped) => eprintln!( + "buzz-desktop: team-suffix-strip: removed the baked team-instructions suffix from \ + {stripped} record(s)" + ), + Err(e) => eprintln!("buzz-desktop: team-suffix-strip: {e}"), + } +} + +/// Core logic, decoupled from the Tauri `AppHandle` for testing. +/// +/// `base_dir` is the managed-agents base directory (`/agents/`). +/// Returns the number of records changed; `Ok(0)` means nothing to do and +/// nothing was written, so a second boot is a clean no-op. +pub(super) fn strip_baked_team_instructions_in_dir(base_dir: &Path) -> Result { + let agents_path = base_dir.join("managed-agents.json"); + if !agents_path.exists() { + return Ok(0); + } + let content = std::fs::read_to_string(&agents_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + let mut all: Vec = serde_json::from_str(&content) + .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + + // Definition hashes BEFORE the strip: stripping a definition's + // `system_prompt` changes its `persona_content_hash`, which is the drift + // basis the Agents menu compares each linked instance's pinned + // `persona_source_version` against. Captured here so instances that were + // current before the strip can be re-pinned after it. + let pre_strip_hashes = definition_hashes(&all); + + // Applies to every record: the definition records carry the suffix as + // surely as the instances minted from them, and neither `team_id` nor + // `persona_id` nor a key is evidence either way. + let mut stripped = 0usize; + for record in all.iter_mut() { + let Some(prompt) = record.system_prompt.as_deref() else { + continue; + }; + // Last occurrence only, mirroring the observer parser's own + // `lastIndexOf` guard (`agentSessionTranscriptHelpers.ts`): a persona + // body may legitimately quote a delimiter-shaped passage, and only the + // final one is the boundary `compose_prompt()` appended. + let Some(at) = prompt.rfind(TEAM_DELIMITER) else { + continue; + }; + let head = &prompt[..at]; + // An empty head means the record held nothing but the baked suffix. + // Store `None` rather than `Some("")`, matching the empty-prompt + // convention in `AgentDefinition::into_agent_record`, so no phantom + // empty prompt is left behind. + record.system_prompt = (!head.is_empty()).then(|| head.to_string()); + stripped += 1; + } + + if stripped == 0 { + return Ok(0); + } + + repin_current_instances(&mut all, &pre_strip_hashes); + + // Pre-migration backup, taken ONCE (same contract as the B5 backfill): a + // re-run after a partial failure must not replace the pristine backup with + // a half-migrated snapshot. Owner-only from the initial open, and sited next + // to the resolved store — see `create_restricted_backup_once` and + // `resolved_backup_path`. + let bak_path = crate::util::resolved_backup_path( + &agents_path, + "managed-agents.json.pre-team-suffix-strip.bak", + ); + crate::util::create_restricted_backup_once(&bak_path, content.as_bytes()) + .map_err(|e| format!("failed to write pre-strip backup: {e}"))?; + + let payload = serde_json::to_vec_pretty(&all) + .map_err(|e| format!("failed to serialize managed-agents.json: {e}"))?; + crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + Ok(stripped) +} + +/// `slug → persona_content_hash` for every definition record, computed through +/// the same projection the drift indicator uses (`persona_drift_state` in +/// `managed_agents/runtime.rs`). +fn definition_hashes(records: &[ManagedAgentRecord]) -> HashMap { + records + .iter() + .filter(|record| record.pubkey.is_empty()) + .filter_map(|record| { + let definition = record.to_definition_view()?; + let hash = persona_content_hash(&persona_event_content(&definition)); + Some((definition.id, hash)) + }) + .collect() +} + +/// Advance the drift pin of instances that were current before the strip. +/// +/// Stripping a definition's prompt changes its `persona_content_hash`, so every +/// linked instance would otherwise light up "out of date" in the Agents menu +/// for a change the user never made — a badge that only clears on the next +/// start, when `apply_persona_snapshot` re-pins. Same conditional as +/// `refresh_builtin_agent_avatars`: move the pin only when it still equals the +/// definition's PRE-strip hash. An instance that had genuinely drifted keeps +/// its stale pin, and its badge. +fn repin_current_instances( + records: &mut [ManagedAgentRecord], + pre_strip_hashes: &HashMap, +) { + let post_strip_hashes = definition_hashes(records); + for record in records.iter_mut() { + if record.pubkey.is_empty() { + continue; + } + let Some(persona_id) = record.persona_id.as_deref() else { + continue; + }; + let (Some(old), Some(new)) = ( + pre_strip_hashes.get(persona_id), + post_strip_hashes.get(persona_id), + ) else { + continue; + }; + if old != new && record.persona_source_version.as_deref() == Some(old.as_str()) { + record.persona_source_version = Some(new.clone()); + } + } +} + +#[cfg(test)] +#[path = "team_suffix_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration/team_suffix_tests.rs b/desktop/src-tauri/src/migration/team_suffix_tests.rs new file mode 100644 index 0000000000..34d240d6bc --- /dev/null +++ b/desktop/src-tauri/src/migration/team_suffix_tests.rs @@ -0,0 +1,449 @@ +use super::strip_baked_team_instructions_in_dir; +use crate::migration::test_support::{read_agents_json, write_agents_json}; +use std::path::{Path, PathBuf}; + +const DELIMITER: &str = "\n\n---\n# Team Instructions\n"; + +fn base(dir: &Path) -> PathBuf { + dir.join("agents") +} + +fn agent_json(name: &str, prompt: Option<&str>) -> serde_json::Value { + serde_json::json!({ + "name": name, + "pubkey": "", + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "parallelism": 4, + "system_prompt": prompt, + "model": "gpt-x", + "provider": "openai", + "env_vars": { "API_KEY": "secret" }, + "start_on_app_launch": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }) +} + +fn prompt_of(dir: &Path, name: &str) -> Option { + read_agents_json(dir) + .into_iter() + .find(|r| r["name"] == name) + .and_then(|r| r["system_prompt"].as_str().map(str::to_string)) +} + +/// Live `persona_content_hash` of the definition with `slug`, computed off the +/// store on disk through the same projection the drift badge uses. +fn definition_hash(dir: &Path, slug: &str) -> String { + let records: Vec = + serde_json::from_value(serde_json::Value::Array(read_agents_json(dir))).unwrap(); + super::definition_hashes(&records) + .remove(slug) + .expect("definition present") +} + +fn definition_json(slug: &str, prompt: &str) -> serde_json::Value { + let mut record = agent_json(slug, Some(prompt)); + record["slug"] = serde_json::json!(slug); + record["display_name"] = serde_json::json!("Paul"); + record +} + +fn instance_json(slug: &str, prompt: &str, pinned: &str) -> serde_json::Value { + let mut record = agent_json("Paul", Some(prompt)); + record["pubkey"] = serde_json::json!("p".repeat(64)); + record["persona_id"] = serde_json::json!(slug); + record["persona_source_version"] = serde_json::json!(pinned); + record +} + +#[test] +fn strip_removes_the_baked_suffix_and_keeps_the_persona_body() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([agent_json( + "Paul", + Some(&format!("You are Paul.{DELIMITER}| Agent | Role |")) + )]), + ); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + assert_eq!( + prompt_of(dir.path(), "Paul").as_deref(), + Some("You are Paul.") + ); +} + +#[test] +fn strip_splits_on_the_last_delimiter_occurrence() { + // A persona body may quote a delimiter-shaped passage; only the final + // occurrence is the boundary the removed compose_prompt() appended. + let dir = tempfile::tempdir().unwrap(); + let quoted = format!("Header.{DELIMITER}quoted example"); + write_agents_json( + dir.path(), + &serde_json::json!([agent_json( + "Duncan", + Some(&format!("{quoted}{DELIMITER}live roster")) + )]), + ); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + assert_eq!(prompt_of(dir.path(), "Duncan").as_deref(), Some(&*quoted)); +} + +#[test] +fn strip_leaves_near_miss_lookalikes_untouched() { + // Each variant differs from the producer boundary by exactly one detail: + // no heading, heading on a different line, a single preceding newline, + // and a trailing-space heading. + let dir = tempfile::tempdir().unwrap(); + let lookalikes = [ + ("bare-rule", "Body.\n\n---\nMore body."), + ("heading-inline", "Body.\n\n--- # Team Instructions\nMore."), + ("single-newline", "Body.\n---\n# Team Instructions\nMore."), + ("heading-only", "Body.\n\n# Team Instructions\nMore."), + ( + "trailing-space", + "Body.\n\n---\n# Team Instructions \nMore.", + ), + ]; + let records: Vec = lookalikes + .iter() + .map(|(name, prompt)| agent_json(name, Some(prompt))) + .collect(); + write_agents_json(dir.path(), &serde_json::Value::Array(records)); + let before = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 0 + ); + let after = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + assert_eq!(before, after, "no lookalike may be rewritten"); + for (name, prompt) in lookalikes { + assert_eq!(prompt_of(dir.path(), name).as_deref(), Some(prompt)); + } +} + +#[test] +fn strip_is_a_no_op_on_the_second_run() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([agent_json( + "Paul", + Some(&format!("You are Paul.{DELIMITER}roster")) + )]), + ); + let path = base(dir.path()).join("managed-agents.json"); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let after_first = std::fs::read_to_string(&path).unwrap(); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 0, + "second run finds nothing to strip" + ); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + after_first, + "second run must not write" + ); +} + +#[test] +fn strip_preserves_every_other_field() { + let dir = tempfile::tempdir().unwrap(); + let mut record = agent_json("Thufir", Some(&format!("Prompt.{DELIMITER}roster"))); + record["pubkey"] = serde_json::json!("t".repeat(64)); + record["persona_id"] = serde_json::json!("sietch-tabr:thufir"); + record["team_id"] = serde_json::json!("team-uuid"); + record["persona_source_version"] = serde_json::json!("a".repeat(64)); + record["auth_tag"] = serde_json::json!("{\"tag\":\"value\"}"); + write_agents_json(dir.path(), &serde_json::json!([record])); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let stored = read_agents_json(dir.path()).remove(0); + assert_eq!(stored["system_prompt"], "Prompt."); + assert_eq!(stored["pubkey"], "t".repeat(64)); + assert_eq!(stored["persona_id"], "sietch-tabr:thufir"); + assert_eq!(stored["team_id"], "team-uuid"); + assert_eq!(stored["persona_source_version"], "a".repeat(64)); + assert_eq!(stored["auth_tag"], "{\"tag\":\"value\"}"); + assert_eq!(stored["env_vars"]["API_KEY"], "secret"); + assert_eq!(stored["model"], "gpt-x"); + assert_eq!(stored["provider"], "openai"); + assert_eq!(stored["parallelism"], 4); + assert_eq!(stored["created_at"], "2026-01-01T00:00:00Z"); +} + +#[test] +fn strip_writes_a_backup_once_and_never_clobbers_it() { + let dir = tempfile::tempdir().unwrap(); + let original = format!("Alia prompt.{DELIMITER}roster"); + write_agents_json( + dir.path(), + &serde_json::json!([agent_json("Alia", Some(&original))]), + ); + let bak = base(dir.path()).join("managed-agents.json.pre-team-suffix-strip.bak"); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let bak_content = std::fs::read_to_string(&bak).unwrap(); + assert!( + bak_content.contains("---\\n# Team Instructions"), + "backup holds the pre-migration bytes" + ); + + // A later record acquiring the suffix must not replace the pristine backup. + write_agents_json( + dir.path(), + &serde_json::json!([agent_json("Alia", Some(&format!("Edited.{DELIMITER}new")))]), + ); + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + assert_eq!( + std::fs::read_to_string(&bak).unwrap(), + bak_content, + "the first backup is never clobbered" + ); +} + +#[test] +fn strip_nulls_a_prompt_that_was_only_the_baked_suffix() { + // Head is empty: the record held nothing but the delimiter and the frozen + // instructions. Storing Some("") would leave a phantom empty prompt that + // reads as "the author wrote a blank prompt"; None is the absent-prompt + // spelling the rest of the store uses. + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([agent_json("Ghost", Some(&format!("{DELIMITER}roster")))]), + ); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + assert!( + read_agents_json(dir.path())[0]["system_prompt"].is_null(), + "no phantom empty prompt" + ); +} + +#[test] +fn strip_skips_records_without_a_prompt() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([agent_json("Promptless", None)]), + ); + let before = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 0 + ); + assert_eq!( + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(), + before + ); +} + +#[test] +fn strip_on_a_missing_store_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(base(dir.path())).unwrap(); + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 0 + ); +} + +#[test] +fn strip_on_an_unparseable_store_errors_without_writing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(base(dir.path())).unwrap(); + let path = base(dir.path()).join("managed-agents.json"); + std::fs::write(&path, "{ not json").unwrap(); + + let err = strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap_err(); + assert!(err.contains("failed to parse"), "unexpected error: {err}"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "{ not json", + "a corrupt store is left for manual recovery" + ); + assert!( + !base(dir.path()) + .join("managed-agents.json.pre-team-suffix-strip.bak") + .exists(), + "no backup is taken when nothing can be migrated" + ); +} + +#[test] +fn strip_repins_an_instance_that_was_current_before_the_strip() { + // The definition's content hash changes when its prompt is stripped. An + // instance pinned to the pre-strip hash was current, so it must stay + // current — the user changed nothing and must not see a drift badge. + let dir = tempfile::tempdir().unwrap(); + let baked = format!("You are Paul.{DELIMITER}frozen roster"); + write_agents_json( + dir.path(), + &serde_json::json!([definition_json("sietch-tabr:paul", &baked)]), + ); + let pre_hash = definition_hash(dir.path(), "sietch-tabr:paul"); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition_json("sietch-tabr:paul", &baked), + instance_json("sietch-tabr:paul", &baked, &pre_hash), + ]), + ); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 2 + ); + + let post_hash = definition_hash(dir.path(), "sietch-tabr:paul"); + assert_ne!(pre_hash, post_hash, "stripping must change the drift basis"); + let instance = read_agents_json(dir.path()) + .into_iter() + .find(|r| r["pubkey"].as_str() == Some(&"p".repeat(64))) + .unwrap(); + assert_eq!( + instance["persona_source_version"].as_str(), + Some(post_hash.as_str()), + "a previously-current instance must not start showing drift" + ); +} + +#[test] +fn strip_leaves_an_already_drifted_instance_pin_alone() { + // An instance pinned to something other than the definition's pre-strip + // hash had genuinely drifted. Silently re-pinning it would erase a real + // signal, so its stale pin — and its badge — survive. + let dir = tempfile::tempdir().unwrap(); + let baked = format!("You are Paul.{DELIMITER}frozen roster"); + let stale = "d".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition_json("sietch-tabr:paul", &baked), + instance_json("sietch-tabr:paul", &baked, &stale), + ]), + ); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 2 + ); + + let instance = read_agents_json(dir.path()) + .into_iter() + .find(|r| r["pubkey"].as_str() == Some(&"p".repeat(64))) + .unwrap(); + assert_eq!( + instance["persona_source_version"].as_str(), + Some(stale.as_str()), + "real drift must keep its signal" + ); +} + +#[cfg(unix)] +#[test] +fn strip_creates_the_backup_owner_only() { + // The backup is a verbatim copy of a store that carries plaintext agent + // nsecs whenever the keyring is unreachable, so it must be owner-only from + // the initial open — a post-write chmod would leave a umask window in which + // the secret is world-readable. + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let mut record = agent_json("Alia", Some(&format!("Alia prompt.{DELIMITER}roster"))); + record["private_key_nsec"] = serde_json::json!("nsec1exampleplaintextkey"); + write_agents_json(dir.path(), &serde_json::json!([record])); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(dir.path())).unwrap(), + 1 + ); + + let bak = base(dir.path()).join("managed-agents.json.pre-team-suffix-strip.bak"); + let mode = std::fs::metadata(&bak).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "backup of an inline nsec must be owner-only"); + assert!( + std::fs::read_to_string(&bak) + .unwrap() + .contains("nsec1exampleplaintextkey"), + "the fixture really did carry an inline key" + ); +} + +#[cfg(unix)] +#[test] +fn strip_backs_up_beside_the_symlink_target() { + // Dev worktrees symlink `agents/managed-agents.json` to a shared data dir + // (`sync_shared_agent_data`). The sole recovery copy must land next to the + // real data, not in whichever worktree booted first. + let shared = tempfile::tempdir().unwrap(); + let worktree = tempfile::tempdir().unwrap(); + write_agents_json( + shared.path(), + &serde_json::json!([agent_json("Alia", Some(&format!("Alia.{DELIMITER}roster")))]), + ); + std::fs::create_dir_all(base(worktree.path())).unwrap(); + std::os::unix::fs::symlink( + base(shared.path()).join("managed-agents.json"), + base(worktree.path()).join("managed-agents.json"), + ) + .unwrap(); + + assert_eq!( + strip_baked_team_instructions_in_dir(&base(worktree.path())).unwrap(), + 1 + ); + + assert!( + base(shared.path()) + .join("managed-agents.json.pre-team-suffix-strip.bak") + .exists(), + "backup belongs beside the shared store" + ); + assert!( + !base(worktree.path()) + .join("managed-agents.json.pre-team-suffix-strip.bak") + .exists(), + "no orphan backup in the worktree data dir" + ); + assert_eq!(prompt_of(shared.path(), "Alia").as_deref(), Some("Alia.")); +} diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 2a93c00185..39dfc988dd 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 1c9ba0095a..f896695624 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 7fb3f94041..2a42d86c2b 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/util.rs b/desktop/src-tauri/src/util.rs index 204cba6dbb..7fa4dc6768 100644 --- a/desktop/src-tauri/src/util.rs +++ b/desktop/src-tauri/src/util.rs @@ -220,6 +220,67 @@ pub(crate) fn configure_no_window(command: &mut std::process::Command) { let _ = command; } +/// Copy `source_bytes` to `path` owner-only, exactly once. +/// +/// For the pre-migration backups taken by the migrations in `crate::migration` before they +/// rewrite `managed-agents.json`. That store carries plaintext agent nsecs +/// whenever the keyring is unreachable (`SECURITY.md`), so the backup must be +/// owner-only from the initial open — `fs::write` then `set_permissions` leaves +/// exactly the umask window that [`atomic_write_json_restricted`] sets the mode +/// on the handle to close. +/// +/// `create_new` also subsumes the create-if-absent check: `AlreadyExists` is the +/// idempotent success case, so a re-run after a partial failure cannot replace a +/// pristine backup with a half-migrated snapshot, and there is no exists-then- +/// write window between the two. `Ok(false)` means a backup was already there +/// and was left untouched. Every other error propagates, so a caller that fails +/// to back up never proceeds to the live write. +/// +/// Non-Unix platforms get `create_new` alone. +/// +/// [`atomic_write_json_restricted`]: crate::managed_agents::atomic_write_json_restricted +pub(crate) fn create_restricted_backup_once( + path: &std::path::Path, + source_bytes: &[u8], +) -> Result { + use std::io::Write as _; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = match options.open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false), + Err(e) => return Err(format!("create {}: {e}", path.display())), + }; + file.write_all(source_bytes) + .map_err(|e| format!("write {}: {e}", path.display()))?; + Ok(true) +} + +/// Derive the sibling backup path for a store, resolving symlinks first. +/// +/// Dev worktrees symlink `agents/managed-agents.json` into the shared canonical +/// dev data dir (`sync_shared_agent_data`). [`atomic_write_json_restricted`] +/// canonicalizes before writing, so without the same resolution here the sole +/// recovery copy would land beside the symlink while the data it protects lives +/// elsewhere. Falls back to the given path when `canonicalize` fails — the store +/// may legitimately not exist yet. +/// +/// [`atomic_write_json_restricted`]: crate::managed_agents::atomic_write_json_restricted +pub(crate) fn resolved_backup_path( + store_path: &std::path::Path, + backup_name: &str, +) -> std::path::PathBuf { + std::fs::canonicalize(store_path) + .unwrap_or_else(|_| store_path.to_path_buf()) + .with_file_name(backup_name) +} + #[cfg(test)] mod tests { use super::slugify; diff --git a/desktop/src-tauri/src/webkit_rendering.rs b/desktop/src-tauri/src/webkit_rendering.rs new file mode 100644 index 0000000000..905da5eeed --- /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 0000000000..5be1612b21 --- /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 07b7216346..85ad5c0b2d 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/App.tsx b/desktop/src/app/App.tsx index 9561b2138c..90b5bf5ebc 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -33,6 +33,7 @@ import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboa import { MachineOnboardingFlow, type MachineOnboardingPage, + type PostOnboardingNavigation, } from "@/features/onboarding/ui/MachineOnboardingFlow"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate"; @@ -592,6 +593,8 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { }); const [machineInitialPage, setMachineInitialPage] = useState(); + const [postOnboardingNav, setPostOnboardingNav] = + useState(null); const reopenMachineConfig = useCallback(() => { setMachineInitialPage("config"); @@ -606,6 +609,27 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { [machine.complete], ); + const navigateAfterOnboarding = useCallback( + (nav: PostOnboardingNavigation) => { + setPostOnboardingNav(nav); + }, + [], + ); + + // Execute the pending navigation once the RouterProvider is mounted (i.e. + // machine.stage transitions to "ready"). We wait for the ready stage rather + // than using setTimeout(0) so the router is guaranteed to exist before we call + // router.navigate(). + useEffect(() => { + if (machine.stage === "ready" && postOnboardingNav) { + void router.navigate({ + to: postOnboardingNav.to, + search: postOnboardingNav.search ?? {}, + }); + setPostOnboardingNav(null); + } + }, [machine.stage, postOnboardingNav]); + const openAddCommunity = useCallback( (payload: AddCommunityDeepLinkPayload & { requestId: string }) => activeCommunity @@ -664,6 +688,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { continueWithIdentity={machine.continueWithIdentity} identityLost={machine.identityLost} initialPage={machineInitialPage} + navigateAfterComplete={navigateAfterOnboarding} queryClient={machine.queryClient} /> {shouldAcknowledgeDeepLink ? : null} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa9348..abbbf29610 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(); @@ -228,6 +231,7 @@ export function AppShell() { const relayConnectionCard = useSidebarRelayConnectionCard( channelsErrorMessage, communitiesHook.activeCommunity?.relayUrl, + `${communitiesHook.activeCommunity?.id ?? "none"}-${communitiesHook.reinitKey}`, ); const memberChannels = React.useMemo( () => channels.filter((channel) => channel.isMember), diff --git a/desktop/src/app/RelayConnectionOverlay.tsx b/desktop/src/app/RelayConnectionOverlay.tsx index faf28ee51e..07c6933d04 100644 --- a/desktop/src/app/RelayConnectionOverlay.tsx +++ b/desktop/src/app/RelayConnectionOverlay.tsx @@ -60,7 +60,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", @@ -86,7 +86,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", diff --git a/desktop/src/app/postOnboardingNav.test.mjs b/desktop/src/app/postOnboardingNav.test.mjs new file mode 100644 index 0000000000..2be2eab95f --- /dev/null +++ b/desktop/src/app/postOnboardingNav.test.mjs @@ -0,0 +1,123 @@ +/** + * Tests for the post-onboarding navigation contract (Thufir F8). + * + * When the user clicks "Set up agents" during machine onboarding, + * MachineOnboardingFlow calls complete() then navigateAfterComplete({to, search}). + * App.tsx records the pending nav and fires it only when machine.stage === "ready" + * (i.e. once RouterProvider is mounted), never before. + * + * These are pure-logic tests — they simulate the App.tsx useEffect predicate + * directly. No React rendering needed; the behavior under test is: + * pendingNav × stage → navigate() call count. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Simulate the App.tsx postOnboardingNav useEffect logic ────────────────── +// +// The production code: +// useEffect(() => { +// if (machine.stage === "ready" && postOnboardingNav) { +// void router.navigate({ to: postOnboardingNav.to, search: ... }); +// setPostOnboardingNav(null); +// } +// }, [machine.stage, postOnboardingNav]); +// +// We simulate this as a pure function and drive it through stage transitions. + +function runEffect(stage, nav, navigate) { + if (stage === "ready" && nav !== null) { + navigate({ to: nav.to, search: nav.search ?? {} }); + return null; // cleared + } + return nav; // unchanged +} + +test("navigate does not fire before stage reaches ready", () => { + const calls = []; + const navigate = (args) => calls.push(args); + + const nav = { to: "/settings", search: { section: "agents" } }; + + // Drive through non-ready stages — navigate must not fire. + for (const stage of ["loading", "onboarding", "blocking"]) { + runEffect(stage, nav, navigate); + } + + assert.equal(calls.length, 0, "navigate must not be called before ready"); +}); + +test("navigate fires exactly once when stage transitions to ready", () => { + const calls = []; + const navigate = (args) => calls.push(args); + + const nav = { to: "/settings", search: { section: "agents" } }; + + // Pre-ready stages — no call. + let pending = nav; + pending = runEffect("onboarding", pending, navigate); + + // Stage reaches ready — navigate fires once. + pending = runEffect("ready", pending, navigate); + + assert.equal(calls.length, 1, "navigate must fire exactly once on ready"); + assert.equal(calls[0].to, "/settings"); + assert.deepStrictEqual(calls[0].search, { section: "agents" }); +}); + +test("navigate does not fire again after nav is cleared", () => { + const calls = []; + const navigate = (args) => calls.push(args); + + const nav = { to: "/settings", search: { section: "agents" } }; + let pending = nav; + + // First ready transition fires and clears. + pending = runEffect("ready", pending, navigate); + assert.equal(calls.length, 1); + assert.equal(pending, null, "pending nav must be cleared after firing"); + + // Re-running the effect with null nav must not fire again. + pending = runEffect("ready", pending, navigate); + assert.equal( + calls.length, + 1, + "navigate must not fire again after being cleared", + ); +}); + +test("navigate fires immediately if nav is set while already ready", () => { + // Edge case: nav arrives after the machine is already ready (e.g. hot reload). + const calls = []; + const navigate = (args) => calls.push(args); + + const nav = { to: "/settings", search: { section: "agents" } }; + + // Start with no pending nav while ready. + let pending = runEffect("ready", null, navigate); + assert.equal(calls.length, 0, "no nav pending → no call"); + + // Nav arrives (complete() called while already ready). + pending = runEffect("ready", nav, navigate); + assert.equal(calls.length, 1, "nav must fire immediately when already ready"); + assert.equal(pending, null); +}); + +test("navigate intent carries exact to and search from navigateToAgentSettings", () => { + // Verifies the specific shape emitted by MachineOnboardingFlow's + // navigateToAgentSettings action: { to: '/settings', search: { section: 'agents' } } + const calls = []; + const navigate = (args) => calls.push(args); + + // Simulate MachineOnboardingFlow calling navigateAfterComplete. + const nav = { to: "/settings", search: { section: "agents" } }; + runEffect("ready", nav, navigate); + + assert.equal(calls.length, 1); + assert.equal(calls[0].to, "/settings", "must navigate to /settings"); + assert.equal( + calls[0].search.section, + "agents", + "must include section=agents", + ); +}); diff --git a/desktop/src/app/useThreadActivityFeedItems.test.mjs b/desktop/src/app/useThreadActivityFeedItems.test.mjs index 6c017028ca..54d36dc1e8 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 af0c50ce48..b14eb51327 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 2af9ddb98c..35ad4a63af 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -67,7 +67,11 @@ with a TypeScript lookup table or an id comparison in a component. 7. **Onboarding setup detects readiness; it does not select defaults.** The setup page derives visible and ready harnesses from the runtime catalog and only offers install or sign-in actions. The following defaults page is the - sole onboarding surface that chooses and persists `preferred_runtime`. + sole onboarding surface that chooses and persists `preferred_runtime`, and + its Finish gate consumes the shared renderer's `onValidityChange` signal — + a harness selection alone does not complete onboarding when the harness + requires provider/model/credential config (e.g. buzz-agent with no + provider). Baked build env and runtime-file config satisfy the gate. `onboarding-agent-defaults.spec.ts` is the acceptance gate for anything touching this flow or the shared renderer. 8. **Omit the Model control only after a confirmed successful empty @@ -102,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 @@ -120,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/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index f182b7b5f7..d78d1d21af 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -154,25 +154,91 @@ describe("activeAgentTurnsStore", () => { }); describe("eviction at MAX_TURNS_PER_AGENT", () => { - it("evicts oldest turn when exceeding 4 concurrent turns", () => { + // Mirrors the store's private cap: the harness's hard upper bound on + // parallel agent subprocesses (`--agents` accepts 1..=32). + const CAP = 32; + /** Desktop's former default harness parallelism; still a legitimate + * per-agent turn count (any value up to the harness cap of 32 is valid, + * regardless of DEFAULT_AGENT_PARALLELISM). */ + const DEFAULT_PARALLELISM = 24; + const EPOCH = Date.parse("2024-01-01T00:00:00Z"); + const at = (ms) => new Date(EPOCH + ms).toISOString(); + + /** One turn_started per channel, minute apart so start order is unambiguous. */ + function startTurns(count, firstSeq = 1) { const events = []; - for (let i = 1; i <= 5; i++) { + for (let i = 1; i <= count; i++) { events.push( makeEvent({ - seq: i, + seq: firstSeq + i - 1, turnId: `t${i}`, channelId: `c${i}`, - timestamp: `2024-01-01T00:0${i}:00Z`, + timestamp: at(i * 60_000), }), ); } - syncAgentTurnsFromEvents(AGENT, events); + return events; + } + + it("evicts oldest turn when exceeding the cap", () => { + syncAgentTurnsFromEvents(AGENT, startTurns(CAP + 1)); const channels = channelIdsOf(getActiveTurnsForAgent(AGENT)); - // Should have evicted c1 (oldest) to make room for c5 - assert.equal(channels.size, 4); + // c1 (oldest) evicted to make room for the 33rd turn. + assert.equal(channels.size, CAP); assert.ok(!channels.has("c1"), "oldest turn should be evicted"); assert.ok(channels.has("c2")); - assert.ok(channels.has("c5")); + assert.ok(channels.has(`c${CAP + 1}`)); + }); + + it("tracks every turn of a high-parallelism agent working in 24 channels", () => { + // Parallelism up to the harness cap (32) is user-configurable, so a + // single agent can legitimately run 24 concurrent turns. Every one + // must keep its working badge. + syncAgentTurnsFromEvents(AGENT, startTurns(DEFAULT_PARALLELISM)); + const channels = channelIdsOf(getActiveTurnsForAgent(AGENT)); + assert.equal( + channels.size, + DEFAULT_PARALLELISM, + "all 24 concurrently-worked channels must be tracked", + ); + for (let i = 1; i <= DEFAULT_PARALLELISM; i++) { + assert.ok(channels.has(`c${i}`), `c${i} must be tracked`); + } + }); + + it("keeps the tracked channel set stable as liveness arrives for every turn", () => { + // The flicker: with the cap below real parallelism, turns above it are + // evicted while still alive, and their 10s turn_liveness frames land on + // resurrectTurn — which evicts one of the survivors to make room. The set + // then rotates forever. Under a cap at the harness maximum, liveness for + // any live turn is a plain refresh and the set never moves. + const TURNS = 6; + syncAgentTurnsFromEvents(AGENT, startTurns(TURNS)); + const expected = channelIdsOf(getActiveTurnsForAgent(AGENT)); + + // Liveness for the two earliest-started turns — the first to be evicted + // under the old cap, hence the first to trigger a resurrection swap. + for (const [i, turnId] of ["t1", "t2"].entries()) { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: TURNS + i + 1, + kind: "turn_liveness", + turnId, + channelId: turnId.replace("t", "c"), + timestamp: at((TURNS + i + 1) * 60_000), + }), + ]); + assert.deepEqual( + [...channelIdsOf(getActiveTurnsForAgent(AGENT))].sort(), + [...expected].sort(), + `liveness for ${turnId} must not change the tracked channel set`, + ); + } + assert.equal( + expected.size, + TURNS, + "all six live turns must be tracked, not a rotating subset", + ); }); }); @@ -1343,16 +1409,17 @@ describe("activeAgentTurnsStore", () => { }); it("evicts the oldest tombstone once past the cap so the map stays bounded", () => { - // The tombstone map is capped at MAX_TERMINAL_TOMBSTONES (16). Complete - // 18 distinct turns so eviction fires twice, dropping the two oldest by - // insertion order (t0, t1). Probe via the ONE behavior a tombstone gates - // that a strictly-newer frame cannot mask: an EQUAL-timestamp liveness - // (frameAt == terminalAt). All completions share timestamp T with rising - // seq, so the probe clears the per-agent watermark on the seq tiebreak - // (compareObserverEvents is timestamp-primary, seq-secondary) yet stays - // equal to the recorded terminal — reaching resurrectTurn's tombstone - // check rather than being shadowed by the watermark. - const CAP = 16; + // The tombstone map is capped at MAX_TERMINAL_TOMBSTONES (MAX_TURNS_PER_AGENT + // * 4 = 128). Complete 130 distinct turns so eviction fires twice, dropping + // the two oldest by insertion order (t0, t1). Probe via the ONE behavior a + // tombstone gates that a strictly-newer frame cannot mask: an + // EQUAL-timestamp liveness (frameAt == terminalAt). All completions share + // timestamp T with rising seq, so the probe clears the per-agent watermark + // on the seq tiebreak (compareObserverEvents is timestamp-primary, + // seq-secondary) yet stays equal to the recorded terminal — reaching + // resurrectTurn's tombstone check rather than being shadowed by the + // watermark. + const CAP = 128; const TOTAL = CAP + 2; const T = at(0); const completions = []; diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index b281929503..3af41ae078 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -23,8 +23,12 @@ const REMOVE_AFTER_MS = LIVENESS_INTERVAL_MS * 2.5; const FRAME_GAP_PAUSE_MS = LIVENESS_INTERVAL_MS * 2; /** A silent agent is treated as dead after this bounded prune pause. */ const PRUNE_PAUSE_MAX_MS = 3 * 60_000; -/** Maximum concurrent active turns tracked per agent (matches pool size). */ -const MAX_TURNS_PER_AGENT = 4; +/** Maximum concurrent active turns tracked per agent. Purely an unbounded-growth + * guard, so it sits at the harness's hard upper bound for parallel agent + * subprocesses (`--agents` / `BUZZ_ACP_AGENTS` accepts `1..=32`) rather than the + * Desktop default of 24 — any lower value silently evicts a live turn, dropping + * its working badge. */ +const MAX_TURNS_PER_AGENT = 32; /** Cap on per-agent terminal tombstones (A's resurrection guard). Only the * most recently completed turns can be raced by a late liveness frame; older * ones are already below the watermark, so a small multiple of the live cap is 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 0000000000..b89f4c61c9 --- /dev/null +++ b/desktop/src/features/agents/assets/agent-outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index edbb106817..70a431a1e5 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -233,7 +233,9 @@ export async function ensureChannelAgentPresetInChannel( name: expectedName, acpCommand: "buzz-acp", agentCommand: input.runtime.command, - agentArgs: input.runtime.defaultArgs, + // Do NOT seed agentArgs from runtime.defaultArgs (see instanceInputForDefinition.ts + // for the rationale — empty args let spawn resolve definition args live). + agentArgs: [], mcpCommand: input.runtime.mcpCommand ?? "", spawnAfterCreate: false, }); @@ -354,7 +356,9 @@ export async function provisionChannelManagedAgent( acpCommand: "buzz-acp", agentCommand: input.runtime.command, harnessOverride: input.harnessOverride ?? false, - agentArgs: input.runtime.defaultArgs, + // Do NOT seed agentArgs from runtime.defaultArgs (see instanceInputForDefinition.ts + // for the rationale — empty args let spawn resolve definition args live). + agentArgs: [], mcpCommand: input.runtime.mcpCommand ?? "", personaId: input.personaId ?? undefined, teamId: input.teamId ?? undefined, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 128ae9083b..122c872e54 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -21,6 +21,7 @@ import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { createManagedAgent, deleteManagedAgent, + deleteCustomHarness, discoverAcpRuntimes, discoverBackendProviders, discoverGitBashPrerequisite, @@ -34,8 +35,10 @@ import { installAcpRuntime, listManagedAgents, listRelayAgents, + saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; +import type { HarnessDefinitionInput } from "@/shared/api/tauri"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -237,6 +240,32 @@ export function useInstallAcpRuntimeMutation() { }); } +export function useSaveCustomHarnessMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + definition, + originalId, + }: { + definition: HarnessDefinitionInput; + originalId?: string; + }) => saveCustomHarness(definition, originalId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + }, + }); +} + +export function useDeleteCustomHarnessMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteCustomHarness(id), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + }, + }); +} + export function useGitBashPrerequisiteQuery() { return useQuery({ queryKey: gitBashPrerequisiteQueryKey, @@ -254,8 +283,9 @@ export function useBackendProvidersQuery(options?: { enabled?: boolean }) { }); } -export function usePersonasQuery() { +export function usePersonasQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: personasQueryKey, queryFn: listPersonas, staleTime: 30_000, diff --git a/desktop/src/features/agents/knownAgentPubkeys.test.mjs b/desktop/src/features/agents/knownAgentPubkeys.test.mjs index 2b7b3e18af..d58b7f5dab 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 978509af09..70cbac68fd 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/agentCardModelLabel.test.mjs b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs new file mode 100644 index 0000000000..696a055d20 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveAgentCardModelLabel } from "./agentCardModelLabel.ts"; + +test("resolveAgentCardModelLabel — unspawned definition with explicit model renders the model, not inherited", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "gpt-5"); +}); + +test("resolveAgentCardModelLabel — unspawned definition with no model renders the default", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: null, + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance inheriting the global default ignores stale persona.model", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "global", model: "stale-model" }, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance with no modelSource (legacy/unset) is treated as inherited", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: null, model: "stale-model" }, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance with an explicit resolved model renders that model", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "definition", model: "gpt-5" }, + personaModel: "should-not-be-used", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "gpt-5"); +}); + +test("resolveAgentCardModelLabel — non-inherited agent with a blank resolved model falls back to the default", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "instance_legacy", model: " " }, + personaModel: null, + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.ts b/desktop/src/features/agents/lib/agentCardModelLabel.ts new file mode 100644 index 0000000000..4ec06f10c5 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardModelLabel.ts @@ -0,0 +1,44 @@ +import { formatAgentModelLabel } from "./formatAgentModelLabel"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Model label for the Agents-page card, shared by the definition-grouped + * card (`AgentPersonaCard`) and the standalone/legacy card + * (`StandaloneAgentCard`). + * + * A materialized `agent` is authoritative once it exists: its `modelSource` + * says whether the *effective* config (from `resolve_effective_config` on + * the backend) came from the global default or from an explicit + * definition/instance value, so the card never has to re-derive that from + * raw model bytes. Absent or `"global"` renders the default-model label; + * anything else renders the agent's own resolved model. + * + * With NO materialized instance yet (a definition that has never been + * started), there is no `modelSource` to read — the definition itself is + * authoritative, so an explicit `personaModel` must render directly rather + * than falling through to "inherited" for lack of an instance. + */ +export function resolveAgentCardModelLabel(input: { + agent: Pick | undefined; + personaModel: string | null | undefined; + defaultModel: string; +}): string { + if (input.agent) { + const isInherited = + !input.agent.modelSource || input.agent.modelSource === "global"; + if (isInherited) { + return formatDefaultModelLabel(input.defaultModel); + } + return input.agent.model?.trim() + ? formatAgentModelLabel(input.agent.model) + : formatDefaultModelLabel(input.defaultModel); + } + return input.personaModel?.trim() + ? formatAgentModelLabel(input.personaModel) + : formatDefaultModelLabel(input.defaultModel); +} + +export function formatDefaultModelLabel(defaultModel: string) { + const model = defaultModel.trim(); + return model ? `Default model (${model})` : "Default model"; +} diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 7fa72f4f3e..62e809bdb5 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 226aafca5e..fabc0af87e 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/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index d4ba32e603..d79c7abcf8 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -142,7 +142,11 @@ test("mapping carries the runtime and definition fields", async () => { assert.equal(input.name, "Test Agent"); assert.equal(input.acpCommand, "buzz-acp"); assert.equal(input.agentCommand, "goose-cmd"); - assert.deepEqual(input.agentArgs, ["--acp"]); + // B-5: agentArgs is intentionally empty at create time — spawn reads args + // live from the definition on every start so definition edits take effect + // without recreating the agent. Seeding from runtime.defaultArgs here would + // freeze args at create-time and silently ignore later definition edits. + assert.deepEqual(input.agentArgs, []); assert.equal(input.mcpCommand, "goose-mcp"); assert.equal(input.personaId, "p-1"); assert.equal(input.systemPrompt, "prompt"); @@ -156,6 +160,8 @@ test("mapping carries the runtime and definition fields", async () => { test("no backend intent is byte-identical to the pre-intent mapping", async () => { // The 3 pre-B5 call sites (useManagedAgentActions, usePersonaActions, // UserProfilePanel) pass no intent; their output must not move. + // B-5: agentArgs is [] — args are NOT seeded from the definition at create + // time. Spawn reads live args from the definition on every start. const input = await buildInstanceInputForDefinition(persona(), gooseRuntime); assert.deepEqual(input, { name: "Test Agent", @@ -164,7 +170,7 @@ test("no backend intent is byte-identical to the pre-intent mapping", async () = avatarUrl: "https://example.com/a.png", acpCommand: "buzz-acp", agentCommand: "goose-cmd", - agentArgs: ["--acp"], + agentArgs: [], mcpCommand: "goose-mcp", harnessOverride: true, model: undefined, diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 7db36b7241..5919309224 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -143,7 +143,13 @@ export async function buildInstanceInputForDefinition( ...base, acpCommand: "buzz-acp", agentCommand: runtime.command, - agentArgs: runtime.defaultArgs, + // Do NOT seed agentArgs from runtime.defaultArgs: record.agent_args must + // remain empty so spawn resolves args live from the definition on every + // start. Seeding here would freeze the args at create-time, silently + // ignoring any later definition-arg edits (Thufir F5 / phase B-5). + // envVars are intentionally never seeded for the same reason (see comment + // at top of this function). + agentArgs: [], mcpCommand: runtime.mcpCommand ?? "", harnessOverride: !persona.runtime || persona.runtime === runtime.id, model: persona.model ?? undefined, 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 9439d4a36e..0000000000 --- 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 38b2d5d974..0000000000 --- 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 0000000000..fbaf1f5274 --- /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 0000000000..02c3f8e202 --- /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 0000000000..c5071d1246 --- /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 0000000000..36f3fcdc8a --- /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 0000000000..f75f0e1c68 --- /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 0000000000..c7835f9373 --- /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 a1cbbf93fe..0dc12ddfd1 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 e713ed71d1..f18194c5c6 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 0000000000..0c84e99275 --- /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 1bdd93673b..e7a68211dc 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], @@ -120,6 +134,7 @@ export function AgentDefinitionDialog({ runtimesLoading = false, onOpenChange, onSubmit, + publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { @@ -157,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: { @@ -211,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]); @@ -296,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. } @@ -347,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) { @@ -381,6 +406,7 @@ export function AgentDefinitionDialog({ enabled: open, }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { + setHasUserChanges(true); setAiConfigurationMode(nextMode); setIsCustomProviderEditing(false); setIsCustomModelEditing(false); @@ -552,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"; @@ -620,19 +617,14 @@ export function AgentDefinitionDialog({ ); const previewLabel = displayName.trim() || "Agent name"; const previewAvatarUrl = avatarUrl.trim() || null; - const runtimeWarning = - selectedRuntime && selectedRuntime.availability !== "available" ? ( -

- {selectedRuntime.availability === "adapter_missing" - ? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.` - : selectedRuntime.availability === "adapter_outdated" - ? `${selectedRuntime.label} ACP adapter is outdated — reinstall to continue.` - : selectedRuntime.requiresExternalCli - ? `${selectedRuntime.label} CLI is missing. ${selectedRuntime.installHint}` - : `${selectedRuntime.label} is not installed.`}{" "} - Visit Settings > Agents to set it up. -

- ) : null; + const runtimeWarningText = selectedRuntime + ? runtimeAvailabilityWarning(selectedRuntime) + : null; + const runtimeWarning = runtimeWarningText ? ( +

+ {runtimeWarningText} Visit Settings > Agents to set it up. +

+ ) : null; const advancedFieldsTransition = shouldReduceMotion ? { duration: 0 } : ADVANCED_FIELDS_MOTION_TRANSITION; @@ -679,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); @@ -696,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") { @@ -714,6 +721,7 @@ export function AgentDefinitionDialog({ } function handleModelDropdownChange(nextValue: string) { + setHasUserChanges(true); applySelection( selectionOnModelDropdownChange(selection, { nextValue, @@ -740,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); + }} />
@@ -962,6 +966,12 @@ export function AgentDefinitionDialog({ returnFocusRef={aiDefaultsTriggerRef} /> + + {isCreateMode ? createRunSection : null}
@@ -1012,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 0000000000..92428ad95c --- /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 0000000000..50109143cd --- /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 02a6d0e64a..f5be3cc7e8 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 86a70c3db6..f3c410e2ff 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; @@ -655,30 +678,36 @@ export function AgentInstanceEditDialog({ parsedParallelism > 0 && parsedParallelism !== agent.parallelism ? parsedParallelism : undefined, - // Use tri-state: send null to clear, value to set, omit if unchanged. + // Linked instances defer model/provider/systemPrompt to the definition. systemPrompt: - (systemPrompt.trim() || null) !== agent.systemPrompt - ? systemPrompt.trim() || null - : undefined, + linkedPersona != null + ? undefined + : (systemPrompt.trim() || null) !== agent.systemPrompt + ? systemPrompt.trim() || null + : undefined, model: - normalizedModel !== (agent.model ?? null) - ? normalizedModel - : undefined, + linkedPersona != null + ? undefined + : normalizedModel !== (agent.model ?? null) + ? normalizedModel + : undefined, // Tri-state provider persistence keyed on providerRuntimeCapability: // "capable" → persist: value if changed, omit if unchanged. // "locked" → clear: send null if provider was set, else omit. // "unknown" → omit always (never send null for a transient state). // llmProviderFieldVisible is for UX visibility only; not used here. provider: - providerRuntimeCapability === "capable" - ? normalizedSubmitProvider !== (agent.provider ?? null) - ? normalizedSubmitProvider - : undefined - : providerRuntimeCapability === "locked" - ? (agent.provider ?? null) !== null - ? null + linkedPersona != null + ? undefined + : providerRuntimeCapability === "capable" + ? normalizedSubmitProvider !== (agent.provider ?? null) + ? normalizedSubmitProvider : undefined - : undefined, // "unknown" → omit always + : providerRuntimeCapability === "locked" + ? (agent.provider ?? null) !== null + ? null + : undefined + : undefined, // "unknown" → omit always envVars: envVarsEqual(submitEnvVars, agent.envVars) ? undefined : submitEnvVars, @@ -943,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 ad1219310d..4a9584dfb9 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 34d40ee809..ce463a4d9b 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, @@ -17,12 +17,14 @@ import { AgentSnapshotImportDialog } from "./AgentSnapshotImportDialog"; import { TeamSnapshotExportDialog } from "./TeamSnapshotExportDialog"; import { TeamSnapshotImportDialog } from "./TeamSnapshotImportDialog"; import { TeamShareDialog } from "./TeamShareDialog"; -import { RelayDirectorySection } from "./RelayDirectorySection"; 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"; @@ -72,11 +74,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 @@ -108,18 +113,23 @@ export function AgentsView() { return ( <>

-
+
{runningAgentCount > 0 ? ( @@ -137,11 +147,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 @@ -188,10 +196,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} @@ -234,17 +240,6 @@ export function AgentsView() { personas={personas.libraryPersonas} teams={teamActions.teams} /> - -
@@ -308,9 +303,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} @@ -322,8 +319,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} /> @@ -349,8 +360,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; @@ -377,6 +400,7 @@ export function AgentsView() { personas.handleExportSnapshot( personas.personaToExportSnapshot.persona, personas.personaToExportSnapshot.linkedAgentPubkey, + personas.personaToExportSnapshot.effectiveAvatarUrl, memoryLevel, format, ); @@ -409,8 +433,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 70d063098b..4fdd6db26f 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/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index 35de0d983d..8412792a2c 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -211,29 +211,32 @@ export function EditAgentAdvancedFields({
- {/* System prompt override */} -
- -
-