Skip to content

[upstream-sync] Merge block/buzz 16cc3de6d..769ac70b7 (16 commits) - #22

Merged
adrienlacombe merged 17 commits into
mainfrom
upstream-sync-20260807
Aug 7, 2026
Merged

[upstream-sync] Merge block/buzz 16cc3de6d..769ac70b7 (16 commits)#22
adrienlacombe merged 17 commits into
mainfrom
upstream-sync-20260807

Conversation

@adrienlacombe

@adrienlacombe adrienlacombe commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Daily sync of block/buzz into this fork.

Range: 16cc3de6d..769ac70b7 — 16 upstream commits, 105 files, +11,214 / −1,535.

What changed upstream

Identity & pairing

Desktop

Relay / media / agents

CI

Conflicts

None — clean merge. Both parents preserved (git cat-file -p HEAD | grep -c '^parent ' → 2; git rev-list --count upstream/main ^HEAD → 0).

Upstream touched three fork-local patch sites; all three merged cleanly and were re-read to confirm the fork's markers still mean what they did:

File Upstream's change Fork patch status
.github/workflows/ci.yml +13, new "Media read-auth e2e" step Intact. mesh-llm-rev patch untouched and still a no-op — both locks pin tag=v0.74.0 (e60b2fe4), confirmed in clippy output. Kept per AGENTS.md.
desktop/src-tauri/src/lib.rs +1, registers start_identity_recovery_pairing Intact. Single-instance argv filter still calls deep_link::is_supported_deep_link (lib.rs:123).
mobile/lib/features/pairing/pairing_provider.dart +70/−8, identity-recovery send path Intact. ensureRelayHostAllowed still guards _validateRelayUrl (:727).

Verification

All run locally on this merge commit.

Gate Result
cargo fmt --all --check PASS
cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --all --check PASS
cargo clippy --workspace --all-targets -- -D warnings PASS
cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings PASS
cargo metadata --locked PASS
scripts/test-release-ref-contract.sh PASS (exit 0)
scripts/test-mobile-worktree-overrides.sh PASS
just test-unit PASS — 956 tests across 7 suites, 0 failed
dart format --set-exit-if-changed . PASS — 368 files, 0 changed
flutter analyze PASS — No issues found
flutter test PASS — 1258 passed
desktop/scripts/check-file-sizes.mjs PASS

Extra checks for known silent-breakage paths in this fork:

  • No new migrations. migrations/ untouched; 30 files, no duplicate version integers.
  • No event-kind changes. kind.rs, kinds.ts, nostr_models.dart carry no KIND_* diff. Fork kinds 30900/30901/30902 and their assertions present.
  • Sidecar lists still agree. tauri.conf.json's externalBin unchanged; macos-canary.yml and all three release.yml lanes still build the matching six.
  • Brand rename intactproductName: BitcoinMarkets, identifier: app.bitcoinmarkets.desktop, scheme bitcoinmarkets.
  • release.yml untouched by upstream in this range, so no gate-reachability review was needed.

Needs a human look

1. Upstream's identity recovery sends an nsec over the pairing relay, which this fork does not allowlist.

This is the one item worth a decision. It is not a regression from this syncPairingSocket (mobile/lib/features/pairing/pairing_socket.dart) has never carried an allowlist check and this sync does not touch that file — but block#4845 changes what flows across it.

  • The community-relay transport is guarded (relay_socket.dartensureRelayUrlAllowed). PairingSocket is a separate class and a separate transport, connecting to qr.relays.first straight from the scanned QR code.
  • Previously the phone only received credentials over that link. With mode=recover it now sends its nsec to whatever relay the QR names.
  • Mitigations already in place: the payload is NIP-44 encrypted under a conversation key derived from ECDH with the source pubkey, and the transfer is gated on the user confirming the SAS code — so an attacker-supplied relay is a transport, not a decryptor. Upstream also surfaces sendsIdentityToDesktop in state so the UI can warn.
  • Decision for a human: whether the fork's single-relay posture should extend to PairingSocket. In this fork a Nostr key also controls a Starknet account, so an exfiltrated nsec is spendable — which raises the stakes relative to upstream. Deliberately not changed here; that is a design change, not a merge resolution.

2. Media reads become mandatory-auth on the live relay when this merges — checked, and it is safe.

769ac70b7 (block#4610) makes every media GET/HEAD require Blossom t=get auth plus current relay membership, and removes the opt-out. Merging deploys that to relay.bitcoinmarkets.app, so I verified the three ways it could have bitten this fork:

  • Config: BUZZ_REQUIRE_MEDIA_GET_AUTH / BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer read (the relay warns if set). infra/aws/ and deploy/ set neither, so nothing to change and no startup warning.
  • Installed desktop builds: already unaffected. sign_blossom_get_auth_header has been attaching the header since f30876285 (feat(media): require auth for relay media reads block/buzz#1926, 2026-07-15), long before this fork's current main — this commit only rewrote its doc comment. Existing auto-updated installs keep working.
  • Mobile: already sends it — mobile/lib/shared/relay/media_auth.dart builds BUD-01 t=get headers and is wired through media_image.dart.

The one real behaviour change is that an identity in recovery mode (signing unavailable) now gets its media reads rejected instead of silently falling back to unauthenticated. Upstream states that is intentional, so a media URL is not treated as a bearer capability.

3. desktop/src-tauri/src/lib.rs is at 992 lines, 8 under the 1000-line ratchet, after upstream's +1. The check passes today. AGENTS.md already records that this file has no headroom (it is why mod allowlist lives in relay.rs); worth knowing the margin is now 8 lines.

Nothing else. No wire-format change, no fork patch whose meaning shifted, no AGENTS.md update required — no patch was deleted, no kind moved, no fork-local file added.


⚠️ Merge with a merge commit, not squash. A squash drops the second parent, leaves the merge base stale, and makes every later sync re-resolve from the same point.

wesbillman and others added 17 commits August 5, 2026 19:42
## Summary

- virtualize the unfiltered channel member roster instead of eagerly
mounting every member card
- retain the existing member search/add flow and archived-member
behavior
- cover a 500-member roster, bounded mounted rows, and scrolling to the
final member in E2E

## Cause

The members sidebar rendered every active member card at once. On large
channels this mounted hundreds or thousands of avatars, profile/presence
consumers, menus, and DOM rows, blocking the renderer even though
fetching the roster itself is fast.

## Testing

- `pnpm typecheck`
- `pnpm exec biome check src/features/channels/ui/MembersSidebar.tsx
tests/e2e/channels.spec.ts`
- `pnpm build:e2e`
- `pnpm exec playwright test tests/e2e/channels.spec.ts --grep 'members
sidebar (virtualizes large channel rosters|can invite relay-authorized
agents|can invite and remove managed agents|collapses same-persona
managed agents)'` (4 passed)
- pre-push: `desktop-check`, full `desktop-test` (4,371 passed),
branch-skew

Implemented by Carl on Wes's behalf.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
…ny — with real nodes (block#3862)

## Summary

CI now proves the full Buzz shared-compute join story end to end: a
member can discover another member's served model **through the Buzz
relay alone** and run inference over the mesh, while a non-member gets
nothing — the relay rejects its auth, and the mesh refuses to route for
it even holding a leaked endpoint address.

This is deliberately different from mesh-llm's own CI smokes (which
bootstrap two nodes with a hand-carried invite token / mdns): here the
**relay is the control plane**, exactly like the desktop app:

1. **Membership** — identities A and B are added via `buzz-admin`
(kind:13534 NIP-43 roster); C is not.
2. **Advertise** — each member publishes a client-signed kind:30003
discovery note carrying its MeshLLM owner binding and (for the serve
node) `serveTargets[].endpointAddr`, covered by an endpoint-binding
signature — the exact payload shape the desktop coordinator publishes.
3. **Trust** — the serve node derives its admission allowlist from the
relay (statuses ∩ roster) and requires the **exact expected {A, B}
owner-id set** before starting with `TrustPolicy::Allowlist`.
4. **Join** — the client verifies owner + endpoint bindings and
membership, then dials the relay-discovered endpoint (the desktop
join-watcher's `dial_endpoint_addr` step). No out-of-band token.
5. **Infer** — a chat completion against the client's local OpenAI
endpoint routes over QUIC to the serve node's model (CPU, SmolLM2-135M,
~105MB).
6. **Deny (differential)** — the stranger's NIP-42 auth must fail with
the relay's own membership rejection (`restricted: not a relay member` —
successful auth or any unrelated connect error fails the run), and
dialing the leaked endpoint must not produce a routed inference —
**while the trusted client re-proves inference immediately afterwards**,
so a dead serve node can't masquerade as an admission denial.

## What's in the PR

- `crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs` — the
harness. One process per node (mesh-llm keeps process-global state under
`~/.mesh-llm`), orchestrator + serve/client/stranger roles,
byte-identical binding payloads to
`desktop/src-tauri/src/mesh_llm/identity.rs` (called out with
keep-in-sync comments). Child stdout is pumped through a reader thread
so every wait has a hard deadline; timed-out children are killed; exit
statuses are checked.
- `scripts/ci-mesh-lifecycle-smoke.sh` — provisions a membership-gated
relay (throwaway owner + signing identities via `buzz-admin
generate-key`), runs the harness, cleans up. Fails fast if :3000 is
already occupied (a stale open relay would mask gating).
- `scripts/start-relay-for-tests.sh` — gains opt-in NIP-43 membership
env passthrough (`BUZZ_REQUIRE_RELAY_MEMBERSHIP` + `RELAY_OWNER_PUBKEY`
+ `BUZZ_RELAY_PRIVATE_KEY`). Default behavior unchanged.
- `.github/workflows/mesh-lifecycle.yml` — separate, path-filtered,
non-required workflow (mesh paths, the harness's dependency crates,
`Cargo.lock`, dispatch), pinned to `ubuntu-24.04`. Caches the mesh
native runtime + HF model keyed on the lockfile hash, so a mesh pin bump
rolls the runtime cache. Uploads relay + harness logs on failure.

## Scope

This is an **independent protocol harness**: it speaks the same wire
protocol and payload shapes as the desktop but re-implements the
binding/verification logic (the desktop crate is outside the workspace).
Regressions inside the desktop's own discovery filtering are the desktop
unit tests' job; what this smoke proves is that the relay + mesh-llm SDK
+ admission stack support the lifecycle end to end.

## Relationship to mesh-llm's CI

Follows the shape mesh-llm's own CI proved stable (tiny CPU model, one
runner, multiple real mesh-llm processes over real QUIC — cf. their
`ci-two-node-client-serving-smoke.sh`), but swaps the token bootstrap
for the relay-driven lifecycle, which is the part only Buzz can test.

## Validation

Green on GitHub Actions (ubuntu-24.04) across three runs, including
after rebases onto the mesh v0.74 upgrade (block#3467) and latest main:

```
PASS 1/6: relay-derived allowlist is exactly {A, B}
PASS 2/6: serve member ready + advertised model: jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M
PASS 3/6: client member discovered + joined via relay
PASS 4/6: inference routed over the mesh: "PONG"
PASS 5/6: relay rejected the stranger's NIP-42 auth (membership gate)
PASS 6/6: stranger denied (gossip visible, inference rejected: 503 all tunnels failed) while trusted inference still routes
PASS: full relay-driven mesh lifecycle verified
```

Also validated locally on macOS. `cargo fmt --all --check` and `cargo
clippy -p buzz-relay --all-targets -- -D warnings` pass.

## Notes

- The harness follows the repo's mesh `[dev-dependencies]` pin
automatically, so it doubles as a canary for future mesh upgrades (it
already caught the v0.73.1 → v0.74.0 bump during development).
- The stranger "deny" accepts either shape mesh-llm exhibits: no model
visibility at all, or gossip visibility with inference refused —
mesh-llm applies the receiving node's owner policy after the gossip
handshake, so admission gates *routing*, not gossip. The differential
trusted-inference re-check (PASS 6/6) is what makes that a real denial
rather than a dead server.
- Model-visibility windows are tunable via `MESH_CLIENT_WINDOW_SECS` /
`MESH_STRANGER_WINDOW_SECS` if shared runners prove slow — pin a longer
window in the workflow env rather than re-running the job.

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
## Summary

- require the macOS process to be running from an actual `.app` bundle
before initializing `UNUserNotificationCenter`
- keep the existing bundle-identifier requirement
- cover packaged, case-insensitive `.app`, raw `target/debug`, and
extensionless paths

## Why

PR block#4799 guarded native notification initialization with
`NSBundle.mainBundle.bundleIdentifier != nil`. Tauri embeds a bundle
identifier in raw development executables, so `tauri dev` passed that
guard and `UNUserNotificationCenter.current()` raised an uncaught
`NSInternalInconsistencyException` because LaunchServices had no bundle
proxy.

## Validation

- focused macOS notification tests: 6 passed
- direct raw debug executable no longer raises the notification-center
exception
- pre-commit formatting hook passed
- pre-push package checks passed on pushed commit
`f29a6664d2a863e7b8aa527f6149fd00b183e4de`

The first push attempt hit an unrelated timing-test failure in
`relay_admission::tests::concurrent_429_extends_the_window_for_parked_waiters`;
its focused rerun passed, and the complete pre-push package suite passed
on the next push.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
…the authenticated socket (block#4990)

## Problem

Users on v0.5.5 report "Can't reach the relay" toggling with brief
"connected" flashes (field reports; also the macOS confirmation in
block#4908). block#4737 closed the stuck-reconnect gaps; this is the opposite
failure: the client redials fine, but then kills its own healthy socket.

Mechanism (all on `main`):

1. AUTH succeeds → session emits `connected`
(`relayClientSession.ts:583`), then awaits `replayLiveSubscriptions()`.
2. Paged channel backfill issues history REQs
(`relayReconnectReplay.ts`, page limit 500).
3. A `CLOSED rate-limited:` on a **history** REQ arms the rate-limit
gate but still rejects the history promise
(`relayClosedRecovery.ts:38-51`).
4. The rejection escapes `replayLiveSubscriptions()` →
`resetConnection()` tears down the authenticated socket.
5. Reconnect → AUTH OK → replay rate-limited again → loop. Each
iteration re-spends the rate-limit budget, so the loop is
self-sustaining.

## Fix

Contain backfill failures inside the replay. Each subscription's paged
backfill now retries behind the rate-limit gate up to
`PAGE_REPLAY_MAX_ATTEMPTS` (3), then degrades to live-only **for this
connection**. Socket health no longer depends on backfill success.
Nothing is lost: the replay cursor (`lastSeenCreatedAt`) only advances
on delivered events, so the next reconnect replays the same missed
window.

## Red/green proof

- Commit 1 (Pinky): e2e injecting `CLOSED rate-limited:` into the
mid-replay history REQ — **red on main** (expected 1 reconnect dial,
observed 2; connected-flash then teardown).
- Commit 2 (this fix): same test **green unchanged** — one dial, state
stays `connected` through the rate-limit hint plus the next backoff
window.

Why existing coverage missed it: the prior rate-limit e2e pre-armed the
gate *before* replay (replay politely waits), and the CLOSED-injection
test targeted a *live* subscription (which has its own retry path).
Nobody injected back-pressure from the history REQ itself.

## Verification

- `pnpm test`: 4374/4374 pass.
- `playwright test tests/e2e/relay-reconnect.spec.ts`: 14/14 pass,
including the new spec.
- `tsc --noEmit` clean; Biome clean on touched files (pre-existing
warnings on main in `personaCatalogRelay.test.mjs` / `terminal.css`
untouched).

## Not addressed here (follow-ups from the same field reports)

- AUTH terminal latch is too aggressive for relay-internal `error:`
rejections (3 strikes during a relay bad window → stuck until
click/relaunch; block#4908).
- Server-side: `relay.drainJitterMs` (block#4542) defaults to 0 — enabling it
on the hosted relay removes the deploy thundering herd that triggers
these rate-limit storms.

---------

Signed-off-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
## Summary
- add a persistent vertical pill beside the active community
- keep the selected state visually distinct from unread dots and mention
badges
- preserve the existing `aria-current` selection semantics

## Screenshot
![Selected community
indicator](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz/prs/5000/selected-community-indicator-v2.png)

## Test plan
- `pnpm exec biome check src/features/sidebar/ui/CommunityRail.tsx
tests/e2e/community-rail.spec.ts`
- `pnpm test` (4,387 passed)
- `pnpm build:e2e && pnpm exec playwright test
tests/e2e/community-rail.spec.ts --project=smoke` (20 passed)
- pre-push hooks: desktop check and 4,387 desktop tests passed on
`c1e80c66d12f73c4eb5c03a19e932439b14caf2d`

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
## Summary
- add stable three-step guidance to desktop mobile pairing
- move code confirmation inline and show animated completion states
- preserve pairing reset behavior and reduced-motion support

## Test plan
- `pnpm --dir desktop check`
- `pnpm --dir desktop exec tsc --noEmit`
- `pnpm --dir desktop exec playwright test
tests/e2e/mobile-pairing-qr.spec.ts --project=smoke`
- pre-push desktop suite: 4,387 tests passed

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Fizz <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
## Why
The focus/split E2E test could capture the thread root before its
programmatic middle-thread scroll had settled, then incorrectly report a
scroll-restoration failure.

## What
- Poll until the requested middle-thread scroll position is applied
- Require the captured anchor to intersect the thread viewport and
differ from the root
- Preserve the existing focus-to-split-to-focus viewport assertions

## Risk Assessment
Low — test-only synchronization change with no production behavior
changes.

## References
- Original failure:
https://github.com/block/buzz/actions/runs/30231271427/job/89870533541
- Buzz thread:
buzz://message?channel=12dd513d-45fd-48ff-80ac-8596d2fcc9d3&id=87ce6024b4bf74bfac2fa75d9f7bbbcc8f8fe2df460afe534152c495929f51ba
- Reproduced confidence: 20 consecutive targeted passes, full spec pass,
`just desktop-ci`, and `just ci`

Generated with Codex

Signed-off-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz>
Co-authored-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
## Summary
- replace ambiguous avatar play controls with centered Start and Restart
pills
- preserve avatar clipping while smoothly morphing actions into the
running status dot
- use accessible warning contrast and real restart behavior without a
duplicate status badge

## Validation
- `just ci`
- focused Playwright coverage for morphing, shared geometry, and
light/dark contrast

Signed-off-by: kenny lopez <klopez4212@gmail.com>
…ges (block#4959)

## Problem

When `buzz-agent` exhausts retries on a stalled LLM call, the error
message reads:

```
transport: error sending request for url (...) (cumulative 721s, 3 attempts)
```

That text is reqwest's generic pre-response failure string — identical
whether the cause is a TLS abort, a reset connection, or a
`read_timeout` fire. An operator reading the log cannot tell whether
something broke or whether the LLM generation legitimately took longer
than the configured timeout.

## Root cause (probe-confirmed)

A live probe against `goose-claude-fable-5` with a 900s client timeout
completed in **370s** — well past the default
`BUZZ_AGENT_LLM_TIMEOUT_SECS=240`. Extended-thinking models emit zero
bytes on non-streaming calls until generation is complete, so reqwest's
`read_timeout` fires on byte-silence regardless of whether the server is
healthy. The 46× exact-721s stall signatures in production logs (3 ×
240s + backoff) are deterministic self-inflicted timeouts, not network
faults.

## Fix

### Pure classifier over `{is_connect, llm_timeout, phase}`

A new `timeout_message(is_connect: bool, llm_timeout: Duration, phase:
TimeoutPhase)` pure function produces factual messages with the
configured duration value embedded verbatim. Two thin wrappers
(`classify_transport_error`, `classify_body_read_error`) extract the
reqwest flags and delegate. The duration reaches the classifiers through
a new `read_timeout: Duration` parameter on `post()` and
`openrouter_post()`; callers pass `cfg.llm_timeout`.

### Messages emitted

| Case | Message |
|---|---|
| Connect-phase timeout (`is_connect && is_timeout`) | `connect timeout:
no connection established within 10s` |
| Transport read-timeout | `read timeout: no response bytes received
within 240s (consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)` |
| Body-read timeout | `read timeout: no further response bytes received
within 240s (consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)` |
| Non-timeout | `transport: {reqwest text}` / `body read: {reqwest
text}` (unchanged) |

`LLM_CONNECT_TIMEOUT` is now a named `const` (was inline
`from_secs(10)`).

**Out of scope by explicit decision:** streaming support, changes to
`MAX_RETRIES` or backoff.

## Files changed

- `crates/buzz-agent/src/llm.rs` — `timeout_message` pure fn +
`TimeoutPhase` enum + `LLM_CONNECT_TIMEOUT` const; two classifier
wrappers updated; `post()` and `openrouter_post()` gain `read_timeout`
param; tests replaced.

## Tests

`cargo test -p buzz-agent`: **397 passed, 0 failed** at `294ce5897`.

**Pure-function tests (no network):**
- `timeout_message_connect_true_shows_connect_timeout` —
`is_connect=true` → connect-flavored text with 10s value; both phases
checked
- `timeout_message_transport_phase_shows_read_timeout_and_duration` —
transport phase includes 240s and config knob
- `timeout_message_body_read_phase_says_no_further_bytes_and_duration` —
body phase says "no further", shows 300s
- `timeout_message_duration_is_not_hardcoded` — 600s supplied → 600s in
output, not 240s

**Loopback reqwest integration tests:**
- `classify_transport_error_read_timeout_is_loopback_verified` — TCP
connect succeeds, server sends no bytes; verifies reqwest sets
`is_timeout && !is_connect` and message contains 50ms value
- `classify_transport_error_non_timeout_preserves_reqwest_text` —
controlled accept-then-close on an owned loopback listener → non-timeout
error; asserts exact `transport: {err}` output equality
- `classify_body_read_error_timeout_says_no_further_bytes` — loopback
server sends headers + 4 bytes of a declared-1024-byte body, then holds;
verifies `is_timeout`, "no further", 100ms value, config knob

No test performs egress beyond loopback (`127.0.0.1`). The TEST-NET-3
dial is deleted.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…k#4845)

**Category:** new-feature
**User Impact:** People who lose a desktop identity can securely restore
it from a signed-in Buzz phone without creating a replacement identity.

**Problem:** A fresh or identity-lost desktop could not recover its
existing full Buzz identity from an already-authorized phone.

**Solution:** Add a SAS-confirmed reverse NIP-AB transfer, durable
desktop import, a dedicated mobile recovery entry point, and clearer
desktop recovery dialogs with tested loading, drag-and-drop, and failure
states.


https://github.com/user-attachments/assets/e9215c9c-80d0-462f-9161-0fa184ca2f74

<details>
<summary>File changes</summary>

**crates/buzz-core/src/pairing/session.rs**
Adds the reverse encrypted payload and source-completion state
transitions used for phone-to-desktop recovery.

**desktop/src-tauri/src/commands/identity.rs**
Exposes the existing guarded identity commit path for recovery imports.

**desktop/src-tauri/src/commands/pairing.rs**
Adds recovery-mode pairing, durable nsec import, start serialization,
stale-task protection, and explicit rejection of unsupported recovery
payloads.

**desktop/src-tauri/src/lib.rs**
Registers the recovery pairing command.

**desktop/src/app/App.tsx**
Refreshes the recovered identity before continuing onboarding.

**desktop/src/features/onboarding/machineOnboarding.ts**
Adds recovery transitions to the onboarding state machine.

**desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx**
Adds the visual backup-to-password-to-unlock progression.

**desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx**
Implements QR generation, copy fallback, SAS confirmation, cancellation,
expiry, and completion UI.

**desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx**
Connects private-key, phone, and backup recovery paths to the onboarding
flow.

**desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx**
Polishes recovery dialogs, backup drag-and-drop, loading stability, and
security copy.

**desktop/src/shared/api/tauri.ts**
Keeps the existing pairing API surface focused on standard
desktop-to-mobile pairing.

**desktop/src/shared/api/tauriPairing.ts**
Adds the recovery pairing invoke without growing the ratcheted shared
API file.

**desktop/src/testing/e2eBridge.ts**
Mocks recovery pairing commands and lifecycle events for browser tests.

**desktop/tests/e2e/identity-lost.spec.ts**
Covers lost-identity entry, QR/copy recovery, SAS, cancellation, expiry,
success, errors, backup import, drag-and-drop, and screenshots.

**desktop/tests/e2e/onboarding.spec.ts**
Verifies recovered identities continue through harness setup without
replacement-key side effects.

**mobile/lib/features/pairing/pairing_page.dart**
Adds recovery-only scanning and explicit identity-handoff warnings.

**mobile/lib/features/pairing/pairing_provider.dart**
Recognizes recovery codes, returns the signed-in nsec after mutual SAS
approval, and waits for desktop completion.

**mobile/lib/features/settings/settings_page.dart**
Accepts the recovery route builder at the app composition boundary to
preserve feature isolation.

**mobile/lib/features/settings/settings_page/connection_section.dart**
Adds the signed-in “Send identity to desktop” settings action.

**mobile/test/features/pairing/pairing_page_test.dart**
Covers recovery-only validation and handoff messaging.

**mobile/test/features/pairing/pairing_provider_test.dart**
Covers reverse payload encryption, confirmation ordering, success,
failure, timeout, and cleanup.

</details>

## Reproduction steps

1. Launch Buzz Desktop with identity-lost state and choose **Recover
from your phone**.
2. Confirm the QR and persistent **Copy pairing code** fallback appear
without layout shift.
3. On a signed-in phone, open **Settings → Send identity to desktop**,
scan or paste the recovery code, and compare the six-digit SAS on both
devices.
4. Confirm on both sides and verify Desktop restores the identity and
continues to harness setup.
5. Repeat from identity-lost state with **Recover from a backup file**;
verify picker and drag-and-drop both advance to password entry and
restore the encrypted backup.
6. Exercise cancellation, mismatched/unsupported codes, expired
sessions, and an invalid backup; verify each returns actionable,
non-stuck UI.

## Screenshots

### Desktop phone recovery — complete flow

| Recovery entry | Pairing QR | Code match | Receiving identity |
|---|---|---|---|
| ![Desktop recovery
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-01-recovery-entry.png)
| ![Desktop phone recovery
QR](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-02-qr.png)
| ![Desktop security-code
match](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-03-sas.png)
| ![Desktop receiving
identity](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-04-receiving.png)
|

### iOS Simulator — complete handoff flow

| Settings entry | Recovery scanner | Manual recovery code | Code
confirmation |
|---|---|---|---|
| ![iOS Settings entry for Send identity to
desktop](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-01-settings-entry.png)
| ![iOS recovery scanner
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-02-recovery-entry.png)
| ![iOS manual recovery code
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-03-manual-code.png)
| ![iOS security-code
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-04-sas-verification.png)
|

### Encrypted backup recovery — adjusted file flow

| File picker | Drag-and-drop target | Password step |
|---|---|---|
| ![Desktop encrypted-backup file
picker](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-01-file-picker-settled.png)
| ![Desktop encrypted-backup drag-and-drop
target](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-02-drag-drop.png)
| ![Desktop backup password
step](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-03-enter-password.png)
|

## Verification

- `cargo test -p buzz-core pairing` — 71 passed
- `just mobile-test` — 1,169 passed
- `pnpm build:e2e && pnpm exec playwright test identity-lost.spec.ts
--project=smoke` — 15 passed
- Full pre-push gates — desktop checks, desktop unit tests, Rust tests,
Tauri checks, and mobile tests passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
The local pre-push gate ran biome (`desktop-check`) and node:test
(`desktop-test`) for desktop changes but never `tsc`, so TypeScript
errors surface no earlier than CI's `desktop-core` job (`just
desktop-build` = `tsc && vite build`). A branch with type errors passes
every local hook today.

This adds a `desktop-typecheck` pre-push command running `just
desktop-typecheck` (`tsc --noEmit`) with the same glob/exclude as
`desktop-check`, and updates the hook documentation in `AGENTS.md`. CI
is unchanged — it already typechecks via `desktop-build`.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…age boot (block#5086)

Fixes the bug where running a dev build with stale localStorage would
publish outdated channel sections, sort preferences, starred channels,
and muted channels to the relay, clobbering the DMG installation's live
state.

## Root cause

All four sidebar-preference sync managers (`channelSectionsSync`,
`channelSortSync`, `channelStarsSync`, `channelMutesSync`) collapsed
five distinct fetch outcomes — no event, timeout, error, auth-race empty
result, decrypt/parse failure — into a single `null`. Each hook's boot
effect treated `null` as "no remote exists" and seed-published whatever
was in localStorage, stamped at `max(now, lastRemoteCreatedAt+1)` with
`lastRemoteCreatedAt` reset to 0 on every boot. A dev build with stale
localStorage therefore re-signed old state as newer, and the DMG's live
subscription applied it.

## Two guards

**1. Tri-state fetch result** (`found | absent | failed`) — decrypt
failure on an existing event reports `failed` and records
`event.created_at`, so seed-publish is blocked even when the payload is
unreadable.

**2. Persisted head watermark** (`sidebarSyncWatermark.ts`) — keyed
`{blobType, pubkey, normalizedRelayUrl}`, written to localStorage on
every observed remote event (before decrypt on all paths: initial fetch,
live subscription, `fetchOwnBlobBeforePublish`), hydrated at
construction. Any session that has ever seen a remote blob skips
seed-publish on the next boot even when the fetch returns empty. Relay
URLs are normalised via `shared/lib/normalizeRelayUrl` (also used by
profile storage) so the same relay written two ways never produces two
keys.

**Bootstrap owns the seed.** Each manager exposes
`bootstrap(localStore)` that fetches, records the raw head, and
delegates the decision to the single `runBootstrap` policy: hold on
`failed` or `absent + prior watermark`, seed on genuine first-sync
(`absent + zero watermark + non-empty local`), `apply-remote` when a
blob was found. Hooks only act on `apply-remote`; they cannot publish
during bootstrap. First-time sync is unchanged: successful EOSE with no
event, zero watermark, and non-empty local state still seeds.

## LWW baseline preservation

`fetchOwnBlobBeforePublish` for sections/sort snapshots the watermark
before `recordRemoteHead` advances it, then compares the fetched event
against the snapshot — advancing first would make `remote.createdAt >
lastRemoteCreatedAt` always false and silently kill the whole-blob LWW
merge. Stars/mutes merge per-entry via `mergeStores`, so no snapshot is
needed there.

## Relay lifecycle

All four hooks require a defined `relayUrl` (plumbed from
`communitiesHook.activeCommunity?.relayUrl` in `AppShell.tsx`); while it
is undefined no manager is constructed and no boot/live/reconnect effect
binds. All effects depend on `[pubkey, relayUrl]`, so community switches
tear down and rebind. `destroy()` cancels pending publishes without
flushing — flushing would race community switching and could publish
relay A's state to relay B via the shared `relayClient` singleton.
Pending debounce-window edits are intentionally dropped: stars/mutes
entries survive via per-entry merge on the next publish; a dropped
sections/sort edit is lost because bootstrap whole-blob-replaces from
remote on return.

Known trade-off: a first boot with the relay unreachable holds (never
seeds) until the user's next explicit edit — preferred over risking a
stale seed-publish.

## Files

- `sidebarSyncWatermark.ts` — watermark persistence + `runBootstrap`
policy (tri-state `FetchResult`, `readWatermark`, `advanceWatermark`)
- `shared/lib/normalizeRelayUrl.ts` — relay-URL normalisation shared by
watermark keys and profile storage
- `channelSectionsSync.ts`, `channelSortSync.ts`, `channelStarsSync.ts`,
`channelMutesSync.ts` — tri-state fetch, pre-decrypt `recordRemoteHead`
on all paths, sections/sort watermark snapshot for LWW, `bootstrap()`,
cancel-without-flush `destroy()`
- `useChannelSections.ts`, `useChannelSortPreference.ts`,
`useChannelStars.ts`, `useChannelMutes.ts` — act on `bootstrap()`
results, gate on `relayUrl`, `[pubkey, relayUrl]` deps on all effects
- `AppShell.tsx` — passes `activeCommunity?.relayUrl` to
`useChannelMutes` and `useChannelStars`
- `sidebarSyncTestHelpers.mjs` — shared fake-window/localStorage/Tauri
mocks for the four manager suites
- Test suites — mutation-sensitive coverage: `failed→hold`,
`absent+watermark→hold`, first-sync seeds, undecryptable head recorded
on all paths, relay-A/B watermark isolation, watermark restart
round-trip, sections/sort LWW baseline

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Owners and admins of a Buzz community get a desktop notification the
first time a new key joins their community. Requested by Tyler in
buzz-development ("we already have this [roster] — can we alert owners
and admins when a new key joins for the first time?"); design and
verification thread: channel `community-members-visibility`.

## Why the shape is what it is

- **kind:13534 membership snapshot is the alerting signal, not the
kind:8000 delta.** 8000 is leaky on two independent axes: its fan-out is
pod-local (no Redis hop — being fixed separately in block#4887), and
`buzz-admin add-member` publishes no 8000 at all by documented design.
The 13534 snapshot is the only signal covering every production join
path with cross-pod delivery (completeness audit: every
membership-insertion path enumerated at base `8342dfcc5`, all emit
13534).
- **This adds Desktop's first live 13534 subscription** — deliberate
line item. The existing read (`relayMembers.ts`) is a one-shot fetch;
without a live subscription no snapshot ever arrives passively and
nothing could fire.
- **8000 is subscribed only as a latency accelerator** and it *refetches
the authoritative snapshot* rather than alerting from its own payload,
so one ledger governs both signals and they cannot double-alert.
- **Persisted per-community/per-viewer ledger, written before the
notification fires.** Snapshot publication is eventual (60s reconciler
repairs failed best-effort publishes) and a reconciler-republished
snapshot is indistinguishable from a fresh one — only a durable record
answers "is this new". Also what makes reconnect replay (`since - 5s`
skew; `since === undefined` full-backlog edge) safe.
- **First snapshot per community seeds silently** (no notification storm
for existing members), and `seeded` is an explicit persisted bit — not
inferred from ledger non-emptiness, which would swallow the first
genuine join in a community whose only member is the viewer.
- Mounted in `useAppShellDesktopNotifications` (owns the
notifications-enabled precondition; `AppShell.tsx` is at the file-size
ratchet ceiling — net growth zero).

5 files, +3103 (production +752, tests +2,351), desktop-only. No relay
changes.

## Verification

**Current reviewed tip: `1854c4a5` — review-blessed code at
`d992ed295ead8c8423f81a752f4ad614718d85c6`** (clean tree, HEAD checked
in the same shell as each gate; history is `0e791f2d3` → merge of main
`2034e693a` → `fdeda44f0` → `5d0d2b4c3` → `a20a7d8cb` → merge of main
`0cfe4832` → `d992ed29` → `1854c4a5`, all fast-forward, no rebase or
force). Independently gated by Eva, Wren, and Sami: typecheck rc=0,
`pnpm check` rc=0 (pre-existing 1 warning / 2 infos), full Desktop unit
package **4431/4431**; push hooks pass. Wren's adversarial verdict at
`d992ed29`: APPROVE — minimalness 9, elegance 9, correctness 9, all four
cancellation seams plus 1b re-derived independently. `1854c4a5` is
assertions and comments only — no production behaviour change, so the
test count is unchanged.

**Remediation commits (review thread `community-members-visibility`):**
- `fdeda44f0` — authorization read from the signed snapshot being
reconciled (a demoting/removing snapshot fails closed before it can
disclose the joins it carries); >3 joins collapse to one summary;
8000-triggered refetches coalesce on a 500ms trailing window.
- `5d0d2b4c3` — join alerts coalesce **across** snapshots, not just
within one: a live burst arrives as several growing rosters, so delivery
defers onto a 1.5s trailing quiet window while ledger persistence and
dedupe stay synchronous per snapshot. Max measured 10 banners from 50
real joins before this; the same shape now produces one.
- `a20a7d8cb` — cancellation covers flushes already in flight, not just
queued timers: a generation token (bumped only by `clearPending`) is
rechecked after the profile lookup and before every send, so
demotion/removal/unmount/community-switch landing mid-flush suppresses
delivery; the notification title is captured with the batch rather than
read at send time. Concurrent-flush semantics pinned: a newer authorized
batch neither cancels nor is cancelled by an in-flight flush.
- `d992ed29` — the stale-authorized-frame disclosure, independently
reproduced at `0cfe4832` (held-open refetch released after a newer
demoting frame: `notifications=1`, body naming the joiner, where 0 is
required). Three fixes in one shape: every callback acts on a
per-effect-run session object (community id, viewer, ledger, ordering
state) instead of ambient current values, closing the community-switch
window; a `created_at` fence plus a fail-closed revocation latch, as one
mechanism, because the relay can publish two snapshots in the same
second so neither `<` nor `<=` alone is safe — the invariant is
“revocation wins”, not “newest wins”; and a 5s clamp on the 1.5s
trailing window so a sustained drip cannot defer delivery without bound.
Red-first: the four new arms fail at `0cfe4832` (25/29) and pass after
(29/29).
- `1854c4a5` — the privacy arm now asserts the persisted ledger is
unchanged across the delayed frame's release, not only the notification
count. Mutation-checked: moving the revoked check after the ledger
advance keeps notifications at 0 and passes the old assertion, and is
killed by the new one. Assertions and comments only.

**Mutation testing:** 9/9 mounted-hook mutants killed at `a20a7d8cb`,
each with a control row before and after — role/enabled gates,
reconnect, 8000 authority, failed-write handling and ref ordering,
community re-key/read, and query invalidation. The reducer/storage fix
separately killed 6/6 mutants with 15/0 controls; the foundational
ledger suite killed 9/9. At `d992ed29`: spelling the fence `<=` kills 5
arms; moving the empty-roster guard after the fence advance kills
exactly the fence-advance arm and nothing else (28/29). One
qualification stated rather than buried — moving the fence advance
itself up to the comparison SURVIVES the whole suite. That is an
equivalent mutant, not a coverage gap: the empty-roster guard returns
before the comparison, and authorization rejection latches `revoked` so
a later frame having moved the fence is unobservable. The scope is
written into the test's docstring. At `1854c4a5`: the
revoked-check-after-ledger-advance mutant is killed by the new ledger
assertion (and by the 1b arm).

**Scale/storage correction in `f6e5a3c57`:** the original 5,000-key cap
could evict members still present in a 5,001+ roster, causing them to
re-alert on every snapshot; read-time truncation reopened the same loop
after reload; and a raw quota exception could reject before notification
dispatch. The fix retains every on-roster key, caps only departed keys,
removes read-time truncation, and uses the app's quota-aware writer.
**Final ordering correction in `0e791f2d3`:** a failed post-recovery
write now skips notification and leaves the in-memory ledger unchanged,
so the next snapshot retries and delivers only after persistence
succeeds.

**Live-local matrix vs a real relay, executed at exact unchanged
`d75cc6cd9` and transferred to the current tip:** a 4,800-sequence
differential found zero old/new reducer divergences below the cap while
exercising the positive alert path; its negative control diverged as
required at 5,100 members (old re-alerts 100; new re-alerts 0). The
final hook change affects only the newly tested failed-write branch;
successful writes follow the same alert path exercised live. The live
communities were sub-cap and persisted successfully, so the matrix
remains applicable without a redundant rerun.
- Invite claim: owner and admin each exactly one notification; plain
member zero; 1.5s quiet window held (8000+13534 deduped); both open
clients live-refreshed the roster. Screenshot receipts SHA-256-pinned
and independently replicated.
- **CLI `buzz-admin add-member` (13534-only path):** DB counts moved
8000 `9→9`, 13534 `15→16` — zero accelerator events, exactly one alert
per manager. Proves snapshot-diff alone alerts.
- Plain member: zero notifications **and** zero
`buzz-community-join-seen.v1:*` localStorage keys before/after the join
(gate sits before the ledger).
- Staggered reload + replay dedupe: no alerts from startup
refetch/replay; republished already-seen snapshot produced zero through
a 2s quiet window.
- Community switch: independent per-community seed state; effect
re-keys; one alert per community, quiet window held at exactly two.

**Live re-verification at `d992ed29` is in progress** (Max; the
after-fix matrix leads with the delayed-refetch demotion arm, A→B switch
ledger isolation, the 5s sustained-drip timing, and packaged-app click
routing behind the positive/NIP-43 controls); earlier receipts at
`a20a7d8cb` cover the instrumented storm and cap-boundary re-drive;
earlier live receipts at `fdeda44f0` — privacy matrix
(demote/remove/promote), summary click-through — transfer where the diff
left those paths untouched.

## Known and accepted

- **8000 cross-pod fan-out is broken relay-side** — fixed in block#4887
(separate lane, not a blocker here): on a multi-pod relay the
accelerator only fires on the claim-handling pod; 13534 still covers
everyone, just not instantly.
- **Late-not-lost semantics.** A live frame missed during a
reload/socket gap is recovered by the next snapshot, reconnect refetch,
or remount backfill (`limit: 1`) diffed against the persisted ledger.
One live-run observation of an admin missing an immediate post-reload
fresh join is attributed to harness rate limiting; the recovery paths
above bound the damage to lateness, never duplicates.
- **Remote promotion activates on reload, not on the next snapshot**
(measured by Sami at `fdeda44f0`): the subscriptions are mounted from
the cached membership lookup, so a viewer promoted to admin by someone
else starts receiving join alerts only after a reload, community switch,
or local membership mutation refreshes that cache. Fails safe
(under-notify). Ruled accepted for v1 by Eva; the fix direction
(subscribing before authorization) is a deliberate design change
deferred to a follow-up if product wants instant activation.
- **Cross-user live-delivery staleness reproduced at the PR's own base**
(`2034e693a`, clean relay): a persisted send can fail to appear in an
already-open recipient timeline. Detached from this PR by a pinned-base
discriminator (identical failure with zero PR code) and tracked
separately in issue `6e2bda3092fa`; current main passes 4/4.
- **A stale demoting frame latches a genuine admin until reload or
community switch** (reverse ordering of the stale-frame privacy race,
`d992ed29`): if a snapshot that does not list the viewer as a manager
arrives out of order, the fail-closed revocation latch trips even though
the viewer is still an admin. The invalidation the latch fires refetches
the membership lookup, which correctly returns admin, so `active` stays
true, the effect deps do not change, and the session stays latched.
Fails safe (under-notify, never over-disclose) and consistent with the
promotion-on-reload semantics above. Ruled accepted for v1 by Eva;
self-clearing the latch would cost a third piece of timing state. Pinned
as documented behaviour in `useCommunityJoinAlerts.test.mjs` — and the
suppressed join is re-announced rather than lost, because a latched
session never records it in the ledger.
- **Lifetime-first-only semantics:** ever-seen ledger means
remove→re-add does not re-alert. Flagged for product ruling; one-line
change if re-adds should ping.

---------

Signed-off-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…ock#4978)

**Category:** fix
**User Impact:** Users can navigate back while an identity key is being
created, while Next remains visible and unavailable until creation
finishes.
**Problem:** The key-creation hold hid both navigation actions, leaving
users without an escape route or a clear indication of what would happen
next. **Solution:** Keep the onboarding footer mounted throughout
creation, leave Back enabled, and gate Next on the completed identity
state.

<details>
<summary>File changes</summary>

**desktop/src/features/onboarding/ui/BackupStep.tsx**
Keeps the onboarding navigation footer visible during key creation, with
Back available and Next disabled until the identity is ready.

**desktop/tests/e2e/onboarding-backup.spec.ts**
Covers the loading and completed navigation states so the intended
behavior cannot quietly crawl back out of the pit.

</details>

## Reproduction steps

1. Start desktop onboarding and choose to create a new identity.
2. Submit the profile step and observe the key-creation screen.
3. Confirm Back is enabled while Next is visible but disabled.
4. Wait for key creation to finish and confirm Next becomes enabled.


## Screenshots

| Before | After |
| --- | --- |
| Navigation actions are hidden during key creation. | Back remains
enabled while Next stays visible and disabled. |
| ![Before: key creation screen without navigation
actions](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4978/key-creation-before-bird.png)
| ![After: key creation screen with disabled Next and enabled
Back](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4978/key-creation-after-bird.png)
|

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
**Category:** fix
**User Impact:** Agent cards and catalog listings now show the avatar
belonging to the identity they represent.

**Problem:** Running agent cards could show a stale definition avatar
instead of the concrete agent profile, while adding another publisher's
catalog entry could let local edits repaint that publisher's listing.
This made agent identity look inconsistent across My Agents and the
Agent Catalog.

**Solution:** Treat the concrete agent pubkey profile as authoritative
for running-card avatars, with the linked definition as fallback. Keep
relay publications authoritative for foreign catalog presentation while
using local copies only for linkage and selection state.

| before | after |
|--|--|
| <img width="874" height="592" alt="Screenshot 2026-08-06 at 3 48
43 PM"
src="https://github.com/user-attachments/assets/2cc6c9f7-ea50-413c-9c7b-4d34bd8b4ec7"
/> | <img width="884" height="597" alt="Screenshot 2026-08-06 at 3 48
40 PM"
src="https://github.com/user-attachments/assets/b14a865c-65c4-458f-9c30-d1a557c877d7"
/> |
| agent-set avatar not showing | agent-set avatar is showing |

## Changes

<details>
<summary>File changes</summary>

**desktop/src/features/agents/lib/agentCardAvatar.ts**
Adds the explicit avatar precedence rule for running agent cards and
blocks avatar-dependent actions until the authoritative profile query
settles.

**desktop/src/features/agents/lib/agentCardAvatar.test.mjs**
Covers profile precedence, definition fallback, blank avatar handling,
and the profile-loading transition for linked-agent actions.

**desktop/src/features/agents/lib/personaCatalogRelay.ts**
Keeps publisher-provided catalog identity and behavior fields
authoritative after a local copy is added.

**desktop/src/features/agents/lib/personaCatalogRelay.test.mjs**
Verifies local copies contribute linkage and selection without
overriding publisher presentation.

**desktop/src/features/agents/ui/UnifiedAgentsSection.tsx**
Uses the concrete agent profile avatar before the linked definition
avatar on running-agent cards.

</details>

## Reproduction Steps

### Running agent card uses the agent profile avatar

Use two visibly different, publicly reachable image URLs: **A** for the
saved definition and **B** for the running agent profile.

1. In **Settings → Experiments**, enable **Agent-managed profiles**.
This prevents Desktop from restoring the definition avatar over an
agent's own relay-profile changes.
2. In **Agents**, create an agent with image **A** as its avatar and
start it.
3. In a channel containing that agent, ask it to update its own Buzz
profile avatar to image **B**. The exact CLI operation under the agent
identity is `buzz users set-profile --avatar <image-B-url>`.
4. After the agent confirms the update, reopen **Agents → My Agents**
(or reload the page so its kind:0 profile is fetched again).
5. Verify the running agent card shows image **B**, not definition image
**A**. Open **⋯ → Share** and verify the share flow also uses image
**B**.

Before this fix, the My Agents card and share flow preferred image **A**
whenever the linked definition had an avatar.

### Catalog listing remains publisher-authoritative

This scenario requires a second Buzz identity so the entry is foreign to
the account under test.

1. As the publisher identity, create an agent definition with a
distinctive name, avatar, and instructions, then use **Share → Share to
catalog**.
2. As the test identity, open **Agents → Discover agents**, find that
publication, and add it.
3. In **My Agents**, open the added copy's **⋯ → Edit**, change its
name, avatar, and instructions, and save.
4. Return to **Discover agents** and find the same publisher entry.
5. Verify it remains selected/added but still shows the publisher's
original name, avatar, and instructions—not the test identity's local
edits.

## Validation

- `pnpm test` — 4,376 passed
- `pnpm typecheck` — passed
- `pnpm check` — passed with existing non-error notices

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This change requires a valid signed Blossom authorization request and
current relay membership for every media GET and HEAD request. It
removes the unauthenticated compatibility path and updates desktop reads
to send the required authorization.

This blocks anonymous retrieval and access after relay-membership
revocation. It does not yet bind a blob to its originating channel, so
someone removed from a private channel can still read a known blob while
remaining a relay member. That channel-ACL follow-up remains required
before closing the full finding.

## Testing

- `git diff --check origin/main...codex/security-media-read-auth`
- Rebased onto `origin/main` at `5c98932`
- Full CI pending

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: adrienlacombe <6303520+adrienlacombe@users.noreply.github.com>
@adrienlacombe
adrienlacombe merged commit ff3018a into main Aug 7, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants