1830 Commits

Author SHA1 Message Date
Matt Toohey
6861e18a86 fix(desktop): keep FileCard inside narrow message rows (#8612)
## Summary

Non-image attachment cards ran past their message row in the thread side
pane, and the download icon was clipped off.

`FileCard` is an `inline-flex` box, so it shrinks to fit its content and
is never narrower than its min-content width. That width comes from the
`truncate` (nowrap) filename. The only width cap was `max-w-sm` (24rem),
so a long filename pushed the card out to the full 24rem even when the
pane body was only about 300px wide (the default 380px thread pane).

- Add `max-w-full`, so the cap becomes `min(24rem, 100%)`. The flex
column ancestors already have `min-w-0`, so the percentage resolves
against the pane width. The card shrinks, the filename gets an ellipsis,
and the size and download icon stay visible. Wider surfaces (forum
posts, DMs, wide timelines) look the same because 100% is wider than
24rem there.
- Add a Playwright regression test in `file-attachment.spec.ts`. It
seeds a text attachment with a long filename and opens its thread. It
then checks that the card and its trailing icon stay inside the row and
that the filename is actually truncated. Without the fix, the card's
right edge lands about 82px past the row.

## Before / After

| Before | After |
| --- | --- |
| ![Before: in the 380px thread pane the attachment card runs past the
right edge of its message row, the filename is cut off mid-word, and the
download icon is clipped out of
view](https://github.com/user-attachments/assets/fca47df1-71c5-44d8-91af-a0b3085f1802)
| ![After: the attachment card fits inside the message row, the long
filename ends in an ellipsis, and the "13 KB" size and download icon are
both
visible](https://github.com/user-attachments/assets/3217b650-0296-4b11-942a-cf739056b680)
|

Measured in the default 380px thread pane: before, the card's right edge
sits at 1342px against a row edge of 1260px (+82px overflow); after, it
sits at 1252px (8px inside the row).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 14:40:28 +11:00
Matt Toohey
8216db1c71 chore(toolchain): bump Rust to 1.98.1 for macOS 27 proc-macro loading (#8598)
## Summary

Bump the pinned Rust toolchain from 1.95.0 to 1.98.1 so release builds
work on macOS 27 hosts.

Rust ≤ 1.97 strips Apple-target artifacts with its bundled
`llvm-objcopy`, which leaves the Mach-O `LC_SYMTAB` string pool
(`__LINKEDIT`) only 4-byte aligned. macOS 27's dyld refuses to `dlopen`
such images (`mis-aligned LINKEDIT string pool`). Because cargo applies
the release profile's default `strip = "debuginfo"` to proc-macro dylibs
too, `cargo build --release` of any crate that uses sqlx macros
(`buzz-db`, `buzz-relay`, `buzz-admin`, …) fails on macOS 27: rustc
cannot load `libsqlx_macros-*.dylib`. Dev builds pass because the dev
profile does not strip, which is why this only shows up as a
release/packaging failure.

The fix (llvm/llvm-project#203680, pulled into rustc by
rust-lang/rust#158410 for rust-lang/rust#157750) first shipped in Rust
1.98.0 and was never backported to 1.97.x. `block/buzz-app` pinned
1.98.1 for the same failure in block/buzz-app#529. With this bump the
`CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_STRIP=none` / custom-linker
workarounds people have been using locally are no longer needed.

### What changes

Two commits: the toolchain bump (with the desktop clippy fixes), then
the workspace clippy fixes.

| Location | Change |
|---|---|
| `rust-toolchain.toml` | `channel = "1.95.0"` → `"1.98.1"` (profile
unchanged) |
| `Dockerfile`, `Dockerfile.push-gateway` | `ARG RUST_VERSION=1.95` →
`1.98` |
| `Dockerfile.sprig` | `rust:1.98-alpine3.22` with the manifest-list
digest verified on Docker Hub (shared by the `1.98` and `1.98.1` tags) |
| `.github/workflows/release.yml` | `dtolnay/rust-toolchain` pinned to
the tip of its `1.98.1` branch (`18f04874`) for the Windows desktop
release job |
| `benchmarks/harbor-buzz-orchestra` | `RUST_IMAGE = rust:1.98-alpine` |
| `_ci-rust.yml`, `windows-canary.yml`, `app_state_tests.rs` | stale
"1.95" comments reworded; the `unknown_lints` allow stays with a
version-agnostic rationale |
| `desktop/src-tauri` | lints clippy 1.98 newly raises under `-D
warnings`: `chunks_exact_to_as_chunks` at five fixed-width PCM/UTF-16
decoders, `needless_borrows_for_generic_args` on the `Copy` `norm`
closure in `config_bridge/reader.rs`, and a redundant `Engine as _`
import in `hpke_key_backup_tests.rs` that `use super::*` already
provides |
| Workspace crates (9) | the 30 lints clippy 1.98 newly raises in `cargo
clippy --workspace --all-targets -- -D warnings`, listed below |

Workspace lint fixes:

| Lint | Sites | Fix |
|---|---|---|
| `result_large_err` (20) | `buzz-relay` `api/git/transport.rs` (8),
`api/git/settings.rs` (6), `api/gifs.rs`, `api/workflows.rs`,
`api/profiles.rs`; `buzz-push-gateway` `verify_installation_assertion` |
Clippy 1.98 now checks `async fn`s, so every `async fn … -> Result<_,
axum::response::Response>` trips it. These follow the existing allow
pattern in `api/media.rs` and `nip_fi_http.rs` ("Response is the natural
error type for axum handlers"): module-level in the two git files,
per-function elsewhere. Boxing `Response` would touch every handler for
no runtime benefit. |
| `chunks_exact_to_as_chunks` (3) | `buzz-media` MP4 brand scan;
`buzz-voice` npy f32 reader and stereo test fixture | `chunks_exact(N)`
→ `as_chunks::<N>().0` |
| `question_mark` (3) | `buzz-acp` queue flush fallback and
slash-command mention skip; `buzz-agent` `find_git_root` | `match … {
None => return None }` → `?` |
| `useless_format` (2) | `buzz-auth` nip_fi startup test policies |
`format!("…")` → `"…".to_string()` |
| `useless_borrows_in_formatting` (1) | `buzz-pairing-cli` secret print
| drop the borrow |
| `manual_noop_waker` (1) | `buzz-sdk` broker test `block_on` |
`Waker::noop()` (stable since 1.85) |

All `as_chunks` rewrites keep `chunks_exact`'s remainder-dropping
semantics, and `as_chunks` is stable since 1.88, which is the MSRV.

One change in `api/git/transport.rs` is not a lint fix. Staging that
file trips the pre-commit secret scanner on its existing test-only ES256
fixture (`GIT_TEST_EC_PEM`), and the scanner only honours a suppression
marker on the flagged line. So the `\`-continued string literal is
rewritten as a byte-identical `concat!` whose `BEGIN` line carries
`sadscan:disable kingfisher.privkey.2`.

Left as is on purpose: `Cargo.toml` `rust-version = "1.88.0"` (MSRV
floor, a separate concern), the README/CONTRIBUTING "1.88+" text, Hermit
(no Rust packages there), the lockfiles, and the `(1.95.0)` comment in
`scripts/start-isolated-test-relay.sh` (the pre-commit secret scanner
flags that file's pre-existing dev-only `DATABASE_URL` whenever it is
staged).

### Related issue
Fixes #8074

### Testing
All under rustc/clippy 1.98.1 on a macOS 27 host (Darwin 27.0.0,
aarch64), with no `RUSTFLAGS` / `SDKROOT` / strip overrides in the
environment:

- `cargo build --release -p buzz-db` (the #8074 repro: the release
profile strips the proc-macro dylib): **builds**. The resulting
`libsqlx_macros-*.dylib` (14 MB) has `LC_SYMTAB stroff % 8 == 0` per
`otool -l`. The same build on 1.95.0 died with `mis-aligned LINKEDIT
string pool`.
- `just clippy` (workspace, `-D warnings`) and `cargo fmt --check`
(workspace and `desktop/src-tauri`): pass
- `just desktop-tauri-clippy` (default and `mesh-llm` feature sets):
pass
- `just test-unit`: every crate passes. The `buzz-acp` binary failed
only under pre-push lane contention; rerun solo it is 1060 passed, 0
failed.
- `just desktop-tauri-test`: 3422 passed, 0 failed
- After the lint fixes: lib/bin unit tests for `buzz-acp`, `buzz-agent`,
`buzz-auth`, `buzz-media`, `buzz-voice`, `buzz-sdk`, `buzz-pairing-cli`,
and `buzz-relay`'s `api::git::transport` tests, including the
Postgres-backed
`enforce_mode_git_pack_routes_missing_assertion_exact_bytes`, which
parses the rewritten PEM: pass

`cfg(windows)` code was not linted locally (no Windows target
installed). CI's Windows Rust job runs workspace clippy for
`x86_64-pc-windows-msvc`, and that passes on this branch. CI also builds
the relay, push-gateway and sprig images on the new `rust:1.98` bases
for amd64 and arm64.

No runtime behavior changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 08:13:38 +11:00
Logan Johnson
a38fedb234 fix(desktop): recognize agents without NIP-OA in Huddles (#8544)
🤖
## Summary
In a Huddle (a voice call in a channel), Buzz Desktop decides which
participants are agents. It uses that answer to choose whose text it
speaks aloud, which agents can publish voice, and when the last person
leaves and the Huddle ends.

Desktop currently treats a participant as an agent only when its channel
role is `bot`. #8494 changes the relay so that it gives `bot` only to
agents with a verified owner attestation (NIP-OA). After that change,
agents without an attestation join as ordinary members, and Desktop
treats them as people:
- their messages are not spoken,
- their voice publisher is refused,
- the Huddle stays open after the last person leaves while such an agent
is still connected.

With this PR, Desktop also counts a member as an agent when a trusted
person has published a managed-agent record for it. Desktop already
publishes such a record for every agent it runs, on every machine.
Trusted people are:
- **you**, so your own agents count as agents on all of your machines;
- **the Huddle's owner**, usually the person who started it, so the
agents they added count as agents for everyone who joins.

Some agents without an attestation still count as people: an agent added
by someone who does not own the Huddle, as seen from any machine other
than that person's. The only way to recognize those agents is an owner
attestation. This PR does not sign one.

### Details
- `huddle/roster.rs` (`fetch_huddle_roster`) is now the only place that
decides agent or person for Huddles. A member is an agent if its role is
`bot`, or if the current user or a channel `owner` published a
kind:30177 managed-agent record whose `d` tag is that member's key.
Records from other authors are ignored. Records whose signature does not
verify are ignored.
- All Huddle checks use the roster: voice-publisher authorization,
`get_huddle_agent_pubkeys` (spoken-text gating and the participant
list), the periodic roster refresh, adding an agent, cleanup at the end
of the Huddle, and the last-person check.
- Trusting the owner's records is a compatibility policy for Huddles,
not proof of identity. A record creates no owner. It lets the channel
owner mark a member as an agent for every Huddle behavior that reads the
roster: spoken text, voice publishing, remote transcription and
turn-taking (both skip agent audio), cleanup, and the last-person check.
Before #8494 the client chose the role, so an owner could already do
this by labeling a member `bot`.
- If the managed-agent records cannot be read, Desktop does not drop
agents that have the `bot` role. Spoken text, voice publishing, adding
agents and cleanup keep working for those agents. The Huddle does not
end automatically, because an agent without an attestation could be
counted as a person. When you join, and on each periodic refresh,
Desktop adds the `bot` agents to its stored list and keeps stored agents
that are still members.

### Related issue
Follow-up to #8494 (the relay stores `bot` only for attested agents). No
existing issue found.

### Testing
- New helper-level unit tests in `huddle/roster.rs`. They do not run the
relay query, the pipeline writers or the leave path.
- the `bot` role, the user's own record and the owner's record each mark
a member as an agent;
  - records from other members are ignored;
  - a record with a forged signature is ignored;
- when the record read fails, `bot` agents stay agents, the Huddle
cannot end automatically, and the next successful read restores the full
roster.
- when the record read fails, a joiner with no stored agents still
stores the `bot` agents, an agent known only by its record stays stored,
and a stored agent that left drops out.
- New tests in `huddle/roster_hydration_tests.rs` run the real
`post_connect_setup` and hot-start entrypoints against a fake relay
whose managed-agent record read fails. The voice-model, audio and
pipeline steps are passed in, so the tests replace them. Post-connect
stores the `bot` agent before media setup starts; the hot-start refresh
also keeps a still-member agent known from an earlier record and drops
one that left; the same incomplete roster gives no human count for the
last-person check. The tests fail when either entrypoint skips its
roster read, and when the roster code accepts only complete rosters.
- `cargo test --lib huddle::` (Huddle tests only), `cargo clippy --tests
-D warnings` and `cargo fmt --check` pass in `desktop/src-tauri`.
- Not yet tested in a live Huddle.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-26 16:00:13 -04:00
Bradley Axen
d0dee1cb50 feat(relay): add several people with one kind:9000 (#8575)
## What

A NIP-29 kind:9000 (put-user) can now name up to 100 people, one `p` tag
each. A client sharing a channel with many people can do it in one
signed write instead of one event per person.

- **Role per person:** NIP-29 style `["p", pubkey, role]`. When the
third element is absent or empty, the event's `["role", r]` tag applies.
- **Same checks, per person:** each person gets the checks a single add
does, in the same order: `decide_put_user`, then the agent's
`channel_add_policy`, then the admission rules again under the channel's
membership lock. One refused person no longer refuses the others.
- **Per-person results:** when the event names two or more people, the
OK message reports each result. Only non-empty lists appear, and
refusals are grouped by reason so 100 people stay well under a client's
16 KB receipt limit:
  ```
members:
{"added":[hex…],"updated":[hex…],"already_member":[hex…],"rejected":[{"reason":"…","pubkeys":[hex…]}]}
  ```
- **When the event is refused:** if it is malformed (bad `p` hex,
unknown role, more than 100 people, missing `h`, archived channel), or
if nobody it names may be added. With one person the message keeps
today's `invalid: <reason>`. With several it is `invalid: members: {…}`.
- **One-person events don't change:** a successful single add still
returns an empty OK message, which an existing e2e test pins.
- **Re-adding someone at the role they already have** no longer posts a
second `member_joined` timeline notice. The member-added notification
(kind:44100) still goes out, so an agent that missed its first one is
still re-signaled.
- **Group-state events (39000–39002)** are now published once per event
instead of once per person.

## Cost of a batch

The database work for one event does not grow with the number of people:

- **One membership transaction.** `buzz-db` gains `put_members`: one
transaction, one membership lock, one channel read, one read of every
involved role, the owner count only if a demotion needs it, and one
`UNNEST` upsert with `RETURNING`. `put_member` is now `put_members` with
one person, so there is one implementation of the admission and
last-owner rules, and a single add makes two fewer round trips than
before.
- **Same answers as one write per person.** People are decided in tag
order, and each decision sees the roles earlier people were given,
including the actor's own (an owner who steps down mid-batch can't then
grant admin), and the owners they add or remove. A new buzz-db test pins
this.
- **One policy query.** Validation reads every third-party target's
`channel_add_policy` with one `= ANY($2)` query
(`get_agent_channel_policies`) instead of one per person.
- **One channel-type read** for all the member-added notifications,
instead of one per notification.

What still scales with people is what each person must receive: their
`member_joined` timeline notice and their kind:44100 notification, one
stored event each.

## Why

This is for Solo chat sharing in buzz-app.
[block/buzz-app#1605](https://github.com/block/buzz-app/pull/1605)
routes every share through one helper, `shareChatsWithChannel`, which
today sends one kind:9000 per missing member. A small follow-up there
switches it to one batch, then re-reads the roster and adds anyone still
missing one at a time. That keeps it correct on relays without this
change, which accept the event but ignore every `p` after the first.

The batch stays a signed NIP-29 event rather than a new HTTP endpoint,
per VISION.md and AGENTS.md ("Prefer Nostr events over new HTTP
endpoints").

## Wire changes for existing senders

- Any malformed `p` tag now refuses the event. Before, malformed `p`
tags were skipped and the first valid one was used.
- Extra `p` tags are now targets. Before, they were ignored.
- A third `p` element that isn't a role, such as a NIP-01 relay hint,
refuses the event with `invalid role: …`. No sender in this repo does
that: `build_add_member` sends one bare `p`.

## Tests

**buzz-db (`channel_members` Postgres tests):** new
`put_members_decides_each_person_in_order`: the sole owner can't step
down before an heir is named in the same call, but the heir is still
added; with the heir counted the owner can step down, and the admin
grant after it is then refused; a no-op re-add reports its prior role; a
repeated pubkey refuses the call. The other `channel_members` Postgres
tests pass, including the announce-admission and concurrent `join_role`
tests that exercise `put_member`.

**buzz-relay (`put_user_postgres_tests.rs`), 5 tests:**
- mixed results in one event: added, already a member, an agent with
`nobody` policy rejected, a per-`p` `bot` role, a duplicate `p`, a
single roster publish
- `updated` for a role change, with the event `role` tag as fallback
- a plain member may add someone, but may not grant an elevated role on
a `p`
- a single add keeps its empty OK message
- an all-refused event: single text and grouped JSON
- a malformed `p` tag
- the 100-person cap: 101 refused, 100 accepted

Related relay Postgres groups (`group_state`, `create_with_members`,
`edit_metadata`, `membership`) pass.

**Live relay:** `e2e_relay` against a local relay at 9c27a0f15: 58 of 61
passed, including `test_nip29_batch_put_user_wire` (added to the CI
Relay E2E list) and every NIP-29 put-user, announce, guest and departure
test in that list. The 3 failures don't touch this change: the two
invite tests assume the relay's seeded host, and this relay ran on
`:3030`. `test_unarchive_emits_member_added_notification` timed out once
and passed when re-run, as it did on the first commit.

**Local caveats:** the local `channel_members` roster-fence test and the
deletion tests fail on a shared local database because other tests leave
its fence trigger disabled, so the PostgreSQL Tests job in CI is the
authority for the full suite. The pre-push `rust-tests` hook passed.
`desktop-tauri-checks` failed on `chunks_exact` lints in desktop files
this PR doesn't touch, so the push used `--no-verify`. `cargo clippy
--all-targets` (buzz-db, buzz-relay) and `cargo fmt --check` are clean.

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: claude00 <fb8b76a01257932b8ca35060862c29bd09b2137638573731c2d5074fc02c5385@buzz.block.builderlab.xyz>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-26 11:52:32 -04:00
Logan Johnson
9e8c9fc04d fix(deps): bump ctutils to 0.4.5 for RUSTSEC-2026-0385 (#8560)
🤖
## Summary
On 2026-10-23, RustSec published RUSTSEC-2026-0385 against `ctutils`, a
constant-time helper crate used by the RustCrypto hashing and HMAC
crates. Some of its functions were meant to run in constant time, so
their timing would reveal nothing about secret values, but in versions
before 0.4.4 they could compile to code that branches on those values.
Since the advisory came out, `cargo-deny check` reports advisories as
failed. That breaks `Security Domain / Security` on `main` and on every
PR that runs the Rust security job.

This PR changes `ctutils` from 0.4.2 to 0.4.5 in the three lockfiles
that pin it: `Cargo.lock`, `desktop/src-tauri/Cargo.lock`, and the
`buzz-feature-flags` relay-feature-selection test fixture. No other
packages change.

| Advisory | Crate | Change |
|---|---|---|
| [RUSTSEC-2026-0385](https://rustsec.org/advisories/RUSTSEC-2026-0385)
| `ctutils` (non-constant-time mask generation and `u128` comparisons) |
0.4.2 → 0.4.5 |

### Related issue
N/A. I searched open and closed PRs for `ctutils` and
`RUSTSEC-2026-0385` and found none.

### Testing
- `cargo deny check advisories`: `advisories ok`. On `origin/main` the
Security job fails on this advisory.
- `cargo check --workspace` passes.
- `cargo test -p buzz-feature-flags --test
relay_feature_selection_contract` passes. This test compiles the fixture
with `--locked`.
- The pre-push hook's Rust and Tauri lanes pass.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:39 -04:00
ngoodger-block
76d792f288 BUZZ-357: feat(cli): token-lean agent output format for event reads (#8234)
### Why?

Agents read channel history with `buzz messages get/thread`. Every
returned event includes its 128-hex `sig`. Every agent-authored message
also carries a NIP-OA `auth` tag, which is another owner pubkey and a
128-hex signature. An agent can't use either of these, but pays for them
on every history read. Measured with Claude's tokenizer, that is about
88 tokens per human message and about 180 per agent message, so a
history read shrinks 33% for short human messages and 40% once agent
messages are in the mix (details in the comment below). The existing
`compact` format is fixed at `{id, content, created_at}` (#6884), which
drops the author, tags and reply target, so agents can't use it.

### What?

- **New `--format agent` for event reads.** Applies to `messages
get/thread/search` and `feed get`.
- Drops `sig`, and trims the NIP-OA `auth` tag to `["auth", <owner>]` so
agents can still tell an author is an agent and who owns it.
- Folds the NIP-10 `root`/`reply` tags into a `reply_to` field, using
the same resolver as relay ingest, plus `thread_root` on nested replies.
That's the value `messages send --reply-to` takes.
- Keeps every other tag exactly as `json` output shows it: channel,
mentions, edit targets, diff metadata.
  - Non-event reads return the same output as `json`.
- **Default for agents.** Without `--format`, the CLI falls back to
`BUZZ_OUTPUT_FORMAT`; an empty or unknown value means `json` with a
warning, so a harness/CLI version mismatch never breaks commands.
buzz-acp sets it to `agent` for every agent runtime through
`default_agent_env` and forwards the effective value in the explicit MCP
server environment, so operator or persona env still wins, and `--format
json` still returns full signed events. buzz-agent adds the variable to
its MCP env allowlist.
- **Docs and cleanup.** The managed-agent skill doc (version bumped to
7) and `AGENTS.md` describe the new default. The two duplicate
`format_events` copies in `messages.rs` and `feed.rs` are now one
`commands/event_output` module, which also does the NIP-10 tag parsing.
The `json` and `compact` output contracts are unchanged.



### Example output
https://github.com/block/buzz/pull/8234#issuecomment-6259679347

### Testing

Started `buzz-acp run` with the deterministic task-agent fixture, then
passed its `session/new` MCP environment to Codex 0.163.0 (`codex
app-server`). With no format override the MCP child received `agent`;
operator overrides of `json` and `compact` reached the MCP child
unchanged. The probes used temporary local sessions and made no model
requests.

### Related

Same change on the other agent read surfaces: each drops `sig`, trims
`auth` to `["auth", <owner>]` and keeps thread context.
- block/buzz-app#1388: buzz-app MCP read lines (text, `claimed
owner`/`reply_to`)
- squareup/cash-server#139132: hosted agents' read tools (JSON events,
raw NIP-10 tags)

Generated with Codex

---------

Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 12:54:05 +11:00
Diem Nguyen
427488e2fc fix(desktop): surface a missing DATABRICKS_HOST instead of loading models forever (#8504)
## Summary

If the `databricks` / `databricks_v2` provider is selected but
`DATABRICKS_HOST` isn't set, the Model field stays on "Loading
models..." forever and never shows an error.

How it happens:

1. Desktop discovers Databricks models in-process only when
`DATABRICKS_HOST` is set. Without it, discovery falls back to `buzz-acp
models`. buzz-agent then fails at config time with `DATABRICKS_MODEL
required` (model is checked first) or `DATABRICKS_HOST required`.
2. `formatModelDiscoveryErrorStatus` maps those errors, and
`BUZZ_AGENT_PROVIDER is required`, to a `null` status. The idea was that
the field's Required row would explain the problem.
3. `deriveModelDiscoveryPending` treats "no data and no status" as still
pending. So a failed discovery with a `null` status looks like it is
still loading.

The fix:

- `formatModelDiscoveryErrorStatus` now always returns a status, and its
return type is no longer nullable. A failed discovery therefore always
settles.
- A missing Databricks host shows: "Set DATABRICKS_HOST to your
Databricks workspace URL under Advanced to load Databricks models."
- A missing provider shows a muted "Choose a provider to load models."

Builds that bake `DATABRICKS_HOST` in never reach this path.

### Screenshots

Settings → Agents → Agent defaults, with Buzz Agent + Databricks v2
selected, no `DATABRICKS_HOST`, and model discovery failing with
`DATABRICKS_MODEL required`. Captured from the desktop e2e build with
the mock bridge. The only difference between the two builds is
`personaModelDiscoveryStatus.ts`.

| Before | After |
|---|---|
| ![Before: Model field stuck on Loading
models](https://raw.githubusercontent.com/block/buzz/bbdfc1f8eb91d8f0f5dc6fd98567bc6af62d8117/pr-8504--before.png)
| ![After: Model field asks for
DATABRICKS_HOST](https://raw.githubusercontent.com/block/buzz/bbdfc1f8eb91d8f0f5dc6fd98567bc6af62d8117/pr-8504--after.png)
|

### Related issue

None found. Searched open PRs that touch `personaModelDiscoveryStatus` /
`usePersonaModelDiscovery`, and issues mentioning "loading models".

### Testing

- New jsdom test drives the real `usePersonaModelDiscovery` hook with a
rejected `DATABRICKS_MODEL required` discovery. It asserts that loading
settles and that the status names `DATABRICKS_HOST`. On `main` the test
fails (`modelDiscoveryLoading` stays `true`), and it passes with this
change.
- Updated unit tests in `personaModelDiscoveryStatus.test.mjs`.
- `pnpm test` in `desktop/` passes (6834 + 161 tests). `tsc --noEmit`
and biome are clean.
- Exercised in the desktop e2e build with the mock bridge (screenshots
above). Not yet exercised in the real Tauri app.

Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
2026-10-20 18:03:48 -07:00
Diem Nguyen
8bbfe44f77 fix(deps): bump webpki crates for RUSTSEC-2026-0381/0382/0383 (#8499)
## Summary
On 2026-10-19, RustSec published three advisories against the TLS
certificate crates in our lockfiles. Since then, `cargo-deny check`
fails on advisories. That breaks `Security Domain / Security` on `main`
and on every PR that runs the Rust security job.

| Advisory | Crate | Change |
|---|---|---|
| RUSTSEC-2026-0382 | `rustls-webpki` (distrusted intermediates) |
0.103.15 → 0.103.16 |
| RUSTSEC-2026-0381 | `webpki-roots` (distrusted emSign roots) |
1.0.7/1.0.8 → 1.0.10 |
| RUSTSEC-2026-0383 | `webpki-root-certs` (same roots) | 1.0.7/1.0.8 →
1.0.10 |

This PR bumps all three in `Cargo.lock` and
`desktop/src-tauri/Cargo.lock`. Only these packages change.

`webpki-roots 0.26.11` stays because `tokio-tungstenite` 0.28/0.29 (via
axum and nostr-sdk) still require `^0.26`, and no patched 0.26 release
exists. 0.26.11 is a semver-trick shim that bundles no certificates of
its own. It re-exports `webpki-roots` 1, which now resolves to the
patched 1.0.10. `deny.toml` ignores RUSTSEC-2026-0381 for that shim and
explains why. The ignore should come out once those consumers move to
`webpki-roots` 1.

### Related issue
N/A. I searched for open PRs bumping these crates and found none.

### Testing
- `cargo-deny check`: `advisories ok, bans ok, licenses ok, sources ok`.
On `origin/main` it reports `advisories FAILED`.
- `cargo check --locked -p buzz-ws-client -p buzz-cli -p buzz-relay`
passes.
- The pre-push hook's Rust unit tests pass.

Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
2026-10-20 21:52:14 +00:00
klopez4212
5fefec604f fix(mobile): restore channel icons and align chat colors with desktop (#8478)
## Summary
- Restores `#` and lock icons for public and private channels on Home,
with matching loading placeholders.
- Updates the dark-mode navigation colors to the desktop design-system
tokens (read and unread text, section headings).
- Replaces the chat color picker with the seven desktop accents (Gray,
Chartreuse, Flame, Magenta, Iris, Cobalt, Jade). Saved legacy colors map
to the closest one.
- Uses white text in your own bubbles, and black where white falls below
4.5:1 (Chartreuse, dark-mode Gray). Links (underlined), quotes, tables
and code use the same text color.
- Insets code blocks in bubbles evenly, with corners concentric to the
bubble and even inner padding.
- The swipe-to-reply indicator uses the chosen chat color.

## Testing
- `flutter analyze` and `dart format` are clean. The full mobile suite
passes (3,705 tests).
- Installed and checked on a Pixel 10 and an iPhone Air against a real
community.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Queue <874bf2d815c2c4b459b90438c669c57f2702f3a76bb6e16fa97184259aac3e9e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:12 -04:00
Diem Nguyen
0b53fc699d test(desktop): read only the open effort menu in effort-picker e2e (#8476)
## Summary
`menuValues()` in `claude-effort-picker.spec.ts` read every
`menuitemradio` on the page right after it opened a menu. Dropdown menus
stay mounted for their 100ms exit animation
(`POPOVER_RADIX_MOTION_CLASS`). That means the model menu, which
`pick()` had just closed, could still be in the DOM, and its rows ended
up in the effort list:

```
- Expected  - 0
+ Received  + 6
+   "Default model (Opus)", "Opus", "Claude Sonnet 5", "Haiku", "Claude Opus 4.8", "Custom model...",
    "Adapter default", "max",
```

The helper now waits for the trigger to report `aria-expanded="true"`,
then reads rows only from the menu named in its `aria-controls`. The fix
only touches the test; no product code changes.

### Related issue
The flake was introduced with the spec in #8059. In CI it failed
`Desktop Smoke E2E (2)` on #8473 (all three attempts,
[job](https://github.com/block/buzz/actions/runs/39336482034/job/118347673219))
and failed once before passing on retry on #8474.

### Testing
- **Forced repro.** I temporarily stretched the closed-menu exit
animation to 1500ms using `page.addStyleTag` (not committed):
  - Before: 5/5 failed with the same `toEqual` diff.
  - After: 5/5 passed.
- **Natural repeats.** I ran `--repeat-each` locally with 3–4 workers,
100 runs on each version:
  - Before: 3 failures from the menu-row leak.
  - After: 0.
- Both versions also had the same local-only startup timeouts (`waiting
for getByTestId('open-agents-view')`), which this change doesn't affect.
- **Full spec.** `pnpm exec playwright test --project=smoke
tests/e2e/claude-effort-picker.spec.ts`: 27 passed.
- `biome check` is clean.

Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
2026-10-20 09:53:46 -07:00
Nur Fatima
139f8903d5 fix(desktop): set window title to "Buzz" for Windows taskbar (#6317)
## What
    
The Tauri window title field in tauri.conf.json was an empty string. On
Windows, the taskbar draws its label from the window caption, so the
Buzz entry rendered as an icon with no name.
    
## How
    
Set app.windows[0].title to "Buzz" in desktop/src-tauri/tauri.conf.json
(line 19).
    
This is the only change — one line in one file. The hiddenTitle: true
field on line 26 only affects the macOS title bar and does not interfere
with the Windows taskbar label.

## Before / After
    
    |                 | Before             | After         |
    |-----------------|--------------------|---------------|
    | Windows taskbar | Icon only, no text | Icon + "Buzz" |
    
## Testing
    
    - Verified the JSON is valid after the change
    - hiddenTitle: true is unchanged — macOS behavior is unaffected
    
Fixes [#6295](https://github.com/block/buzz/issues/6295)

Signed-off-by: Fatima Nur <fatimanur424@example.com>
Co-authored-by: Fatima Nur <fatimanur424@example.com>
2026-10-19 15:36:57 -07:00
Diem Nguyen
e73ba22e15 fix(desktop): release the live tail after a single scroll back to bottom (#8465)
## Summary
This fixes the Desktop Smoke E2E failure that turned `main` red at
af7979e ([run](https://github.com/block/buzz/actions/runs/39295686771)).
The failing test was `video-attachment.spec.ts` "playback speed persists
across videos and reloads". The cause is a real timeline bug, not a
problem in the test.

When the reader leaves the bottom, `MessageTimeline` freezes the live
tail and sets a one-shot flag. The flag tells it to ignore the next "at
bottom" report, because freezing the tail can shorten Virtua's model and
produce a false one. If nothing is buffered, the freeze doesn't change
the model, so that false report never arrives. The flag then stays set
and swallows the reader's real return to the bottom. If the reader gets
back to the bottom in one scroll event, the tail stays frozen and new
messages wait behind a "1 new message" pill even though the reader is
already at the bottom. A scrollbar jump, the End key, or Playwright's
scroll-into-view retry all return in one event.

In CI, the Play click was intercepted, so Playwright retried it with
different scroll alignments. That moved the timeline away from the
bottom (scrollTop 430 → 289 → 430, seen in the run's trace), and the
second video message was buffered and never rendered.

Fix: once the freeze commits with nothing buffered, clear the flag. If
the reader is already back at the bottom, release the tail.

### Related issue
None found. No open PR touches this.

### Testing
- New smoke test `virtualization.spec.ts` "a single scroll back to the
bottom releases the live tail": failed 10/10 before the fix, passed
10/10 after (`--repeat-each=10`).
- `video-attachment.spec.ts` "playback speed persists…": 20/20 passed
with `--repeat-each=20`.
- Smoke runs of `virtualization`, `scroll-history`, `video-attachment`,
`messaging`, `smoke`, `file-attachment` and `thread-follow`: 188 passed.
3 failed with a blank page before the first click, and all 3 passed on
re-run. Unmodified `main` shows the same failure locally, so it's a
local preview-server flake and unrelated to this change.
- `pnpm typecheck`, `pnpm check`, and `pnpm test` (6829 + 160 tests) all
pass.

Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
2026-10-19 12:55:52 -07:00
Will Pfleger
6f67dcdac4 fix(desktop): bump zip to 9 for RUSTSEC-2026-0367/0368/0369 (#8412)
🤖 Bumps the desktop app's direct `zip` dependency from 8 to 9 to clear
RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369 on the
managed Node.js archive extraction path.

zip 9 makes `ZipArchive::name_for_index` and `ZipFile::name` fallible.
Managed Node.js zip validation now rejects an entry whose name can't be
decoded, and the "unsafe path" error prints the raw entry name lossily.
The absolute-path, drive-prefix, traversal and `enclosed_name()` checks
are unchanged.

The lockfile update is scoped to `zip`; it pulls in newer `zstd`,
`lzma-rust2` and `ppmd-rust` that zip 9 requires.

**`zip` 8 is still in release builds.** With `--features mesh-llm`,
`mesh-llm` v0.76.2 (`mesh-llm-system`, `mesh-llm-plugin-manager`,
`mesh-llm-host-runtime`) still depends on `zip` 8.6.0. That goes away
when the `mesh-llm` pin moves to a release that includes
Mesh-LLM/mesh-llm#2471.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-18 10:41:21 -07:00
Wes
7b11c6147d test: fix recurring CI flakes in desktop and Postgres lanes (#8343)
Fixes the CI flakes that failed most often across `main` and unrelated
branches from Oct 5 to Oct 14. The counts come from 150 failed `CI`
runs. Every change except the nextest config is test-only.

| Flake | Runs hit | Root cause | Fix |
|---|---|---|---|
| `probe_codex_acp_version_…descendant_holds_pipe_open` | 5 | The test
writes its script in-process and then execs it. If a sibling test spawns
a process in that window, the child inherits the open write fd and Linux
rejects the exec with `ETXTBSY`. The probe swallows that error as
`None`. | Write probe scripts from a short-lived `/bin/sh`
(`write_executable_script`) |
| `empty-edit-delete.spec.ts` (`:51`, `:74`, `:95`) | 11 | The seeded
`mock-general-welcome` id isn't hex, so `formatTimelineMessages` can't
overlay its edit. The post-subscribe window refetch then reverts the
row. | Emit a per-test message with a real id after the exact channel
subscription is live |
| `huddle-transcription.spec.ts:798` | 9 | The composer's delayed
initial autofocus (TipTap `focus()` runs in rAF) lands after the voice
popover opens and dismisses it. | Wait for `message-input` focus before
opening the popover |
| `voice-note.spec.ts:906` | 6 | The fixture `ping.mp3` is about 1s
long, so playback can end before `paused === false` is observed. | Loop
the fixture audio |
| `workflows.spec.ts:291` | 5 | The `Message text` fill runs before the
inspector swaps to the new step, so it fills the trigger's condition
instead. | Scope the fill to the step inspector after its `Action` menu
appears |
| `cluster_global_probe_commits_tokens_and_sessions_prove_coverage` | 3
| The `max-threads = 1` group only serializes these tests with each
other. Other tests' sessions still mask cluster-wide `pg_stat_activity`
(`MaskedActivity`). | `threads-required = "num-test-threads"` |
| `test_usage_metrics_lock_has_single_owner_and_releases_on_drop` | 1
(this PR) | The test retries the lock right after dropping the leader.
Postgres releases a session advisory lock only after the backend notices
the disconnect, so the retry can run first. | Poll for the successor,
bounded at 5s |

### Validation
- **ETXTBSY:** a Linux experiment with Rust 1.95 using the same
write→chmod→spawn pattern under concurrent spawns hit `ETXTBSY` 4–7% of
the time. Using the `sh` writer, it hit 0 of 6,000. I did not catch it
inside the full suite locally, so this fix is based on the mechanism,
not an in-suite repro. All 11 probe tests pass on Linux and macOS.
- **empty-edit-delete:** the baseline failed 16 of 45 runs
(`--repeat-each`, 6 workers, no retries). The spec fix alone brings that
to 4/45. The remaining failures come from a separate product race,
described below.
- **huddle:** the baseline failed 5 of 10 runs. With the fix, 60 of 60
passed without retries.
- **nextest:** checked with a throwaway crate on 0.9.136. A grouped test
with `num-test-threads` ran alone, even with `max-threads = 1`.
- I have not stress-run the voice-note and workflows fixes; they rely on
CI.

### Not in this PR
- **Product bug in `useChannelLinks`:** the debounced `#channel`
autocomplete close lets Enter accept a stale suggestion for about 120ms.
That accounts for the remaining `empty-edit-delete` failures, and it
affects users too. With the spec fix plus a synchronous close, the spec
passed 120 of 120 runs. This needs a product decision, so it's held for
a separate PR.
- **Composer autofocus race:** the delayed autofocus can close an
overlay the user just opened. The real fix is to revalidate when the
delayed focus runs. Overlaps #6530.
- **`thread-head-stale-edit.spec.ts:55`:** a forced click on a
pointer-events-none action bar is suspected, but I couldn't reproduce
it.
- **Remaining ETXTBSY exposure:** about 40 other Rust tests write and
then exec scripts, and 17 specs still edit the non-hex seed message.

Root-cause lanes by Princess Donut, Mongo and Mordecai. Carl integrated
and verified them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <5d97ac8c272fa949af56c71e586146b1706e5c4c3daa125db5b4625e12e0686c@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <81cabd2ca1792494372ed410283c00770c0cf35afca052e940fa522883212b45@buzz.block.builderlab.xyz>
Co-authored-by: Mordecai <f314b6033b6e13df94c8e9e1187d3666b6b9ce54e66ee986e31cdaa909e8dcbc@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:32:00 -06:00
Logan Johnson
67c4fe5e17 fix(deps): bump rustls to 0.23.46 for RUSTSEC-2026-0346 and -0347 (#8319)
🤖
## Summary
The required Security check now fails on every branch, including main.
The dependency audit (`cargo-deny`) reports two new advisories for
rustls 0.23.45, the TLS library behind the relay, CLI and desktop
connections:

- **RUSTSEC-2026-0346:** printing a TLS client session value with
`Debug` (for example, in a log line) shows its TLS secret.
- **RUSTSEC-2026-0347:** when a server rejects Encrypted Client Hello
(ECH, which hides the server name a client asks for), data the client
buffered can go to the outer server.

rustls 0.23.46 fixes both. This PR moves the root and desktop lockfiles
to it, so the Security check passes again.

### Related issue
None found. #8277 fixed an earlier advisory in the same way.

### Testing
- `cargo-deny check` passes: "advisories ok, bans ok, licenses ok,
sources ok".
- `cargo tree --locked` resolves both lockfiles without more changes.
Only the rustls version and checksum change. A plain `cargo update -p
rustls` also moved unrelated `syn` and `getrandom` edges, so this PR
leaves those out.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 15:16:47 +00:00
klopez4212
5dc693aeaf Extract shared Huddle audio client for desktop consumers (#8129)
Huddle audio was embedded in the v0 desktop client, forcing other
clients to duplicate its protocol and codec behavior. This extracts
`buzz-audio-client` so v1 can depend on a pinned library revision
without building the v0 application.

The crate owns the bounded v2 audio handshake, roster parsing, wire
format, Opus sender, and per-peer NetEQ jitter buffer. The v0 human and
agent senders use it, and the desktop retains room admission,
identity/signing, TLS policy, device routing, mute, and teardown.
Receive decoding preserves v1's support for 120 ms Opus packets. No
relay protocol changes, speech models, or model downloads are included.
Docker builder stages include CMake because cargo-chef compiles the
workspace dependency graph.

Dependency security: Opus 0.4 replaces the unmaintained `audiopus_sys`
backend with `opusic-sys`. NetEQ is pinned exactly to 0.8.3 because its
published Rust sources never reference its declared ringbuf dependency.
RUSTSEC-2026-0293 is documented as unreachable for this audited source,
and a wrapper restriction rejects every other consumer of vulnerable
ringbuf versions. Independent review confirmed the scope;
re-audit/remove the exception when changing NetEQ. NetEQ 0.9.1 still
declares ringbuf 0.3, so a version bump alone does not remove it. Local
`cargo deny check` passes with this constrained exception.

Validation:
- All 21 shared-crate tests pass, including real loopback WebSocket
authentication/cancellation, real Opus-to-NetEQ playback, malformed
input, and 120 ms decoding. These tests are included in `just
test-unit`.
- Shared-crate, root workspace, and desktop Tauri Clippy pass; `just
desktop-tauri-check` passes.
- The desktop Tauri test workspace passes; native two-person
listening/device checks remain separate.
- All mandatory push checks pass at `d06bac5dc`, including the complete
Rust unit gate and desktop workspace checks. Existing agent timing and
relay log-capture tests failed under concurrent load, passed separately,
and passed in the full serial test run; no tests or hooks were skipped.
- One test-only fixture change allows cold Perl startup before the
escaped-process test's inner timeout (2 seconds instead of 300 ms). Its
independent 5-second watchdog, 300-second surviving pipe writer, and all
assertions remain intact. All eight process-helper tests pass;
independent review confirmed this still tests bounded drain completion.
- Dependency inspection confirms the library brings in no desktop
application, Tauri, identity store, relay server, or speech-model
crates.
- Independent source review found no correctness blockers.

Draft: a native two-person call, mute/leave/cancellation, and
microphone/speaker switching still need a runtime tryout and human
review. Full `just ci` was stopped during the unrelated mobile toolchain
download because of disk pressure; it is not a passing gate. Consumer
integration is tracked in block/buzz-app#489.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-13 16:24:27 -04:00
Bradley Axen
30873ba46b docs(nest): replace commit attribution policy with the runtime facts (#8236)
## What

The nest `AGENTS.md` template
(`desktop/src-tauri/src/managed_agents/nest_agents.md`) ships to every
Buzz user's agents. Its "Git Commit Attribution" section is cut from a
general attribution policy (separate claims, author "per applicable
policy", hypothetical repos that require a human author, "stop and ask")
down to what the runtime actually does:

- `buzz-acp` sets a default git identity and signing. Agents use it, but
change the author when a repository or their human needs a different
one. The operator can opt out with `BUZZ_GIT_IDENTITY=user`.
- Credit the other party with `Co-authored-by`: the human you work for
when you commit as yourself, or yourself when you commit as them.
- Follow each repository's own `AGENTS.md` for sign-off (DCO) and other
attribution rules.

The buzz-acp base prompt gets the same co-author default.
`NEST_AGENTS_VERSION` goes from 6 to 7 so existing nests refresh, and
the template test now asserts the new content.

## Why

Agents read the old section as a puzzle to solve on every commit. They
looked for an applicable policy, wondered whether the human should be
the author, and stalled. DCO and sign-off rules are per-repository and
already live in each repo's own `AGENTS.md` (this repo says `git commit
-s`), so they don't belong in a template that every OSS user's agents
read.

Companion buzz-app change: block/buzz-app#833.

## Validation

`cargo test --lib managed_agents::nest` in `desktop/src-tauri`: 55
passed, including the legacy-attribution upgrade test.

I pushed with `--no-verify`: the change is Markdown plus a version
constant and a test, and the nest tests above are the relevant suite. CI
runs the full set.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: bundled00 <f172256d14c11991d58561c98a594e7397d64087502e7bc90f2c0e8d0d45363f@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:39:21 +00:00
Bradley Axen
6c388bde62 feat(relay): accept typing indicators over HTTP POST /events (#8207)
## Summary

Agents hosted inside the Buzz app sign their own events but don't hold a
WebSocket, so they couldn't show "typing…" in a channel. `POST /events`
rejected kind 20002 (typing indicator) with `restricted: unknown event
kind`.

Now, when `POST /events` receives a kind 20002 event, the bridge passes
it to the same channel broadcast the WebSocket path uses. Live
subscribers in the channel get the pulse. Nothing is written to
Postgres.

Before broadcasting, the relay checks, in order:
- the event has an `h` tag naming the channel
- the serialized event is at most 2 KiB (real typing events are ~600
bytes). Measuring the whole JSON, not just content and tag values, means
piling on empty tags can't get around the cap
- the signature and id are valid
- bans and timeouts
- the community write fence (over HTTP, a quiescing community's host
already fails to bind with a 404, so this catches quiescing that starts
after the tenant is bound)
- the sender is a channel member, or the channel is open (same rule as
WebSocket)
- the signer is the pubkey that authenticated the request (checked
first; a mismatch returns 403)

The existing HTTP admission rate limit and relay-membership gate still
run before any of this. Other ephemeral kinds are still rejected over
HTTP. `publish_channel_ephemeral` is the old WebSocket channel-broadcast
code moved into a shared helper, so both paths use the same membership
check and fan-out.

Unlike the WebSocket path, HTTP requires the `h` tag. Over WebSocket, a
20002 with no channel goes to every subscriber.

Points left for review:
- HTTP typing pulses count against the same per-pubkey HTTP quota as
message posts (default 300/min). One pulse every 3 s is 20/min per
active channel.
- A failed Redis publish is logged and the POST still returns `accepted:
true`, same as the WebSocket path. A pulse is resent every 3 s and
expires after 8 s, so a retry would only resend a stale pulse.

The `sadscan:disable` comment on an existing test URL in `ingest.rs` is
there because the secret scanner reads every file a commit touches. It
uses the same form as the other local test fixtures in the repo.

### Related issue

Needed for Agents2 typing indicators in buzz-app. The client PR depends
on this one and will follow as a draft.

### Testing

- 9 new Postgres-backed tests in `api/typing_postgres_tests.rs` cover
these cases: a member's pulse is published and not stored; a non-member
gets a 400; a timed-out member gets a 403; a signer mismatch gets a 403;
a missing `h` tag gets a 400; a tampered event gets a 400; kind 20001 is
still rejected; oversized events get a 400 (2 KiB of content, one 2 KiB
tag value, or `h` + 10k empty tags); a community that starts quiescing
after the tenant is bound gets a 400 `community writes are fenced`, and
nothing is published. Two mutation checks: with the fence removed, the
fence test fails; with the old content-plus-tag-values cap, the
empty-tags case is accepted and the test fails.
- The full buzz-relay Postgres lane on 27b5a294: 386/387 pass. The one
failure is
`boot_lifecycle::hanging_redis_peer_times_out_before_the_health_listener_binds`:
it went over its timing slack under full-suite load (8.03 s) and passed
when rerun alone.
- buzz-relay unit tests pass (1476). One mesh demo test timed out once
under load and passed when rerun alone.
- `cargo clippy -p buzz-relay -p buzz-cli -p buzz-acp --all-targets -D
warnings` is clean.
- Live test against a local relay built from this branch (27b5a294), run
by ART at baxen's request as the sign-off test. alice subscribed over
WebSocket to kind 20002 in a private channel; bob (a bot member with no
WebSocket) and carol (outsider) used `POST /events` with NIP-98. 16/16
checks passed:
- bob's top-level pulse → 200 `accepted: true`, and alice received it
live
- bob's thread pulse (`h` + root `e` + reply `e`, the buzz-acp shape) →
delivered with tags intact
- 2 KiB content, a 2 KiB tag value, or `h` + 10k empty tags → 400
`typing indicator too large`; alice received nothing
- carol → 400 `restricted: not a channel member`; alice received nothing
  - carol posting an event signed by bob → 403
  - no `h` tag → 400; kind 20001 → 400 `only accepted via WebSocket`
  - 0 kind-20002 rows in Postgres afterwards

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
2026-10-09 22:44:31 +00:00
Will Pfleger
c1370cc654 chore(release): release Buzz Desktop version 0.5.28 (#8222)
## Buzz Desktop release v0.5.28

- **Frozen main:** `5142cf6b2a0f44686eeda1ed07905ee3560fd78e`
- **Reviewed candidate:** `f87c10895ea35a9a2cea629fef1d7532a3fecbc5`
- **Previous desktop release:** `desktop-v0.5.27`
- **Proposed immutable tag:** `desktop-v0.5.28`

This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.

The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-10-09 16:18:58 -04:00
Luke Tornquist
5142cf6b2a test(desktop): move CSP guards from Rust to the desktop JS suite (#8172)
## Summary

Moves the packaged-app CSP guards from `desktop/src-tauri/tests/csp.rs`
to `desktop/src/app/contentSecurityPolicy.test.mjs`. No production code
changes beyond one added `export`.

`csp.rs` never touched Rust app code. All seven tests parse
`tauri.conf.json` and compare CSP strings. The MediaPipe check also
`include_str!`'d
`desktop/src/features/profile/lib/animatedAvatarCapture.ts` and pulled
the `MEDIAPIPE_WASM_BASE` literal out by splitting the text. That made a
Tauri test depend on a frontend file. With path-filtered CI (#8156), a
change to only that `.ts` file would skip the check.

The JS version imports `MEDIAPIPE_WASM_BASE` directly (it's now
exported) and reads `src-tauri/tauri.conf.json` from disk. The
`desktop/**` CI filter covers both files, so the check runs in Desktop
JS whenever either one changes, and the filter needs no cross-boundary
special case.

The seven tests and their assertions carry over one-for-one, along with
their rationale comments.

**What got simpler:** we delete a 204-line Rust integration test, the
`include_str!` of a frontend file, and the hand-rolled string-literal
scraper, and the CI path filter doesn't need to know about this file.

### Related issue

Addresses the CSP coupling raised in review of #8156. Related: #4614
(introduced the guards).

### Testing

- New suite on Linux: 7/7 pass.
- I falsified each test by mutating the real policy or code, one
mutation at a time. Each mutation fails exactly the matching test, and
the suite passes again once restored:
  - moved `MEDIAPIPE_WASM_BASE` outside `@mediapipe/`
  - dropped `'wasm-unsafe-eval'`
  - dropped the MediaPipe scope from `script-src`
  - added a bare `https://cdn.jsdelivr.net/` origin
  - added `'unsafe-inline'`
  - removed `img-src`
  - dropped `ws:` from `connect-src`
  - made the pinned-source predicate always pass
- Blox `just ci` at tree `5664c6ce` (same tree as head `f798f94b`):
- Passed: fmt, clippy, desktop/Tauri/web checks,
security-review/file-size checks, `desktop-test` (6,819 + 139 jsdom),
`desktop-build`, `desktop-tauri-check`, `desktop-tauri-test`,
`web-build`.
- `test-unit`: 94/95 suites pass. The two failing `buzz-acp`
`git_bootstrap` tests fail because the workstation's Git 2.43 predates
the credential `authtype` protocol. They pass 4/4 with Git 2.51, and
this PR touches no `crates/`.
- Not run: `mobile-check`/`mobile-test`, because the workstation can't
reach pub.dev. No `mobile/` changes.
- Hosted CI green:
https://github.com/block/buzz/actions/runs/37801901659. This PR selects
the Tauri lanes because it deletes `src-tauri/tests/csp.rs`. Later edits
that only touch the constant select Desktop JS only.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-09 15:23:27 -04:00
VSCteam
99924a5a69 fix(desktop): repair the Windows notification shim's false denial on read (#5600)
Addresses #2445. Closely related to #2483 -- see "Relationship to #2483"
below; I am happy to close this in favour of that one.

## The problem

On Windows the notification plugin's injected shim decides its
permission at startup like this
([`guest-js/init.ts:12-18`](https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/notification/guest-js/init.ts)):

```ts
if (window.Notification.permission !== 'default' || __TEMPLATE_windows__) {
  return await Promise.resolve(window.Notification.permission === 'granted')
}
return await invoke('plugin:notification|is_permission_granted')
```

`__TEMPLATE_windows__` is replaced with the literal `true` on Windows,
so the `||` short-circuits and the shim compares its own
freshly-initialised `'default'` against `'granted'` -- `false` -- and
stamps **`denied`**. The line that would ask the backend is unreachable,
and the backend would have said yes: `permission_state()` returns
`PermissionState::Granted` unconditionally on desktop.

So on every launch of the Windows app, `window.Notification.permission`
is `denied` and nothing in the OS is denying anything.

## Why the toggle is only half of it

`getDesktopNotificationPermissionState()` trusts that value, so
`refreshPermission()` reports `denied` at mount, and this effect in
`hooks.ts` runs before the user touches anything:

```ts
React.useEffect(() => {
  if (settings.desktopEnabled && (permission === "denied" || permission === "unsupported")) {
    setSettings((current) => ({ ...current, desktopEnabled: false }));
  }
}, [permission, settings.desktopEnabled]);
```

It writes `desktopEnabled: false` and persists it. Measured on a real
install: with alerts successfully enabled and `desktopEnabled` persisted
as `true`, a clean relaunch comes back with it persisted as `false`.

That means fixing only the enable path leaves a Windows user re-enabling
alerts after every restart.

## What this changes

One place: `getDesktopNotificationPermissionState()` repairs the bogus
value instead of reporting it.

```ts
if (
  window.Notification.permission === "denied" &&
  isTauri() &&
  isWindowsPlatform()
) {
  try {
    return await requestDesktopNotificationAccess();
  } catch {
    return window.Notification.permission;
  }
}
```

`requestPermission()` is the only call that reaches the backend, and on
Windows it raises no prompt, returns `Granted`, and rewrites the shim's
cached value -- so this is self-limiting: the next read short-circuits
as `granted` and never requests again. There is a test for exactly that.

Repairing at the read rather than at the toggle fixes every consumer at
once, including `sendDesktopNotification()`, which gates delivery on
`=== "granted"` and would otherwise drop notifications at boot.

- `denied` stays terminal on macOS, on Linux, and on ordinary Windows
web pages.
- A denial that survives the request is still reported as `denied`.
- A rejected request falls back to the current value instead of
propagating; callers do not handle rejections today.
- Adds `isWindowsPlatform()` to `shared/lib/platform.ts`, alongside the
existing `isMacPlatform()` / `isLinuxPlatform()`.
- No new dependencies. No changes to `hooks.ts` or to notification
delivery.

## Verification on a real Windows 11 install

Buzz Desktop 0.5.9 release build, Windows 11 Home 10.0.26200, WebView2
151.0.4129.72, attached over CDP. Clean boot, nothing injected:

```
window.Notification.permission                -> "denied"
localStorage[...].desktopEnabled              -> false      (written by the effect above)

await window.Notification.requestPermission() -> "granted"
window.Notification.permission                -> "granted"

new window.Notification("Buzz", { body: ... })
ToastNotificationManager::History
  .GetHistory("xyz.block.buzz.app")           -> 3 -> 4     (real toast delivered)
CreateToastNotifier(...).Setting              -> Enabled
```

The OS was permissive throughout: no `ToastEnabled` override, no
notification policy keys under HKCU or HKLM, and the app present under
`HKCU\...\Notifications\Settings\xyz.block.buzz.app`. Full detail in
[this
comment](https://github.com/block/buzz/pull/2483#issuecomment-5255972424).

To be precise about scope: this confirms the mechanism the patch relies
on, measured on a stock release build. The patched build itself is
covered by the unit tests below, not by a packaged Windows run.

## Tests

`pnpm test` -- full desktop suite. New cases in `lib/desktop.test.mjs`:

- Windows Tauri repairs the shim's false denied state on read
- the repaired state is cached, so later reads do not request again
- a denial that survives the request is reported as denied
- denied stays terminal outside the Windows Tauri app (Windows web,
Linux Tauri, Linux web)
- granted is returned untouched and never triggers a request

Plus `shared/lib/platform.test.mjs` for `isWindowsPlatform()` across
`Win32` / `Win64` / `Windows` and `MacIntel` / `Darwin` / `Linux
x86_64`.

## Relationship to #2483

#2483 fixes the same root cause at the toggle: it retries the request
inside `setDesktopEnabled`. That makes the toggle work, which is what
#2445 reports, and its `ensureDesktopNotificationPermission` helper is a
nicer shape than an inline condition.

The difference is where the repair happens. Because #2483 leaves the
mount-time read as `denied`, the effect quoted above still turns the
setting off on every launch, so the fix does not survive a restart.
Repairing at the read covers the toggle path too, since
`setDesktopEnabled` calls `refreshPermission()` first and finds
`granted`.

The two overlap: both add `isWindowsPlatform()` to `platform.ts`, so
whichever lands second needs a trivial rebase. If maintainers prefer
#2483's shape, the equivalent of this PR is to call its helper from
`refreshPermission` as well as from `setDesktopEnabled` -- I am glad to
close this and send that instead. I did not want to push changes into
someone else's open PR uninvited.

## Once this lands

#2445 can close. I am running a per-machine launcher workaround for a
small Windows team until then, which this makes unnecessary.

Signed-off-by: VSCteam <144167621+Joxyko@users.noreply.github.com>
2026-10-09 15:19:56 -04:00
Kalvin C
8f66f7051c feat: raise hosted community limit to fifty (#8186)
## Summary
- Raise the stock relay active ownership quota from 5 to 50 for creation
and ownership transfer. Raise the absolute lifetime quota from 20 to 50
reserved hosts; completed owner deletions still permanently consume
lifetime capacity to bound create/delete host squatting.
- Honor the owner-list `can_create` projection across Desktop settings,
create flow, and onboarding instead of imposing a client-side count.
Display server `quota_used` / `quota_limit` when available; when the
projection is missing, let the server's `limit_reached` decide.
- Cover 49→50 and 50→51 admissions, deleted-owner lifetime exhaustion,
and rendered create gates under 5/50, 0/50 exhausted, and missing
projections.

## Verification
- Desktop full `pnpm test` and `pnpm typecheck` pass after the 50-cap
update; `cargo fmt --all -- --check` passes. Four targeted Playwright
cases and `pnpm build:e2e` passed for the original dynamic-quota change
at the previous commit, before changing test fixtures from 20 to 50.
- `cargo test -p buzz-db --lib` cannot compile here: macOS dyld rejects
the `sqlx_macros` dylib with `mis-aligned LINKEDIT string pool` on this
host before the crate's tests run. CI must validate the DB package.

## Rollout / manual validation
- Confirm `BUZZ_MAX_COMMUNITIES_PER_OWNER` is unset or 50 on **every**
production relay replica; this per-process cached override takes
precedence over the stock default.
- Confirm Builderlab's list/create/transfer proxy passes the relay's
`quota_used`, `quota_limit`, `can_create`, and `limit_reached` without
its own 5-community gate. Its backing implementation is not in this repo
and was not verified here.
- Human test: with an owner at 49 live communities, create the fiftieth,
verify the fifty-first returns `limit_reached`; transfer into an owner
at 49 then at 50 and confirm the same boundary. Check a deleted-owner
account at 0 active/50 lifetime displays the generic limit and disables
creation. This manual check remains outstanding.

---------

Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-10-08 15:40:20 -07:00
Will Pfleger
836e7d0d06 feat(desktop): browse and moderate every community in the Admin Console (#8027)
The Admin Console only ever saw the community the desktop happened to be
connected to. With #8011's community reads, the console can now browse
every community on the relay and, for signed staff, moderate any of them
by name without switching connections. 🤖

## What changes

- **Communities tab.** Lists every live community on the relay,
searchable by host prefix with "Load more" paging. The connected
community is pinned at the top by exact host match. Report rows get a
community badge that opens that community.
- **Community page.** Reports, Restrictions, Members and Actions, each
scoped to the community on the page. The Actions form has no host field:
it always acts on the page's community, and the TypeScript host
normalization is gone from the Actions tab.
- **Native reads** (`admin_list_communities`, `admin_search_members`,
`admin_get_member`, `admin_get_event`). Every request validates the
explicit community host and NIP-98-signs the real method and URL.
Failures come back as a structured error (`status`, `bodyComplete`,
`bodyEmpty`, `code`), so the UI can tell an older relay's empty 404, a
coded `event_not_found` and a transport failure apart, and never says
"not found" unless the relay did.
- **Explicit-host lifts.** `admin_lift_restriction` sends the page's
community host, not the active relay's. It freezes the relay and signer
the intent was reviewed under, and refuses to send if either has
changed.
- **Pending actions survive navigation.** A panel-level
`DirectActionsProvider` holds the frozen intent, so switching
communities or tabs mid-confirm doesn't lose it. Only Discard drops it.
A signer change remounts the controller and drops it.
- **Fenced lookups.** Member and event lookups are keyed by origin,
signer, community, target and a generation counter, so a late answer for
one community can't unlock Review in another.
- **Shared member picker** (`MemberSearchResult`, also used by Add
Member), showing the truncated npub so members with the same name can be
told apart. Delete accepts a note link and previews the message before
Review. Relay staff are blocked before Review.
- **Disabled admin auth is read-only.** With `BUZZ_ADMIN_AUTH=disabled`
the Communities tab and community pages still render, and report badges
still open them, but every ban, timeout, delete, add-member and lift
control is disabled. The settings badge says the console is read-only
and that acting needs `nip98`. Staffing stays hidden.
- #7904's protections in the Actions tab are kept: frozen intent,
`requestId`, Retry, `notSent` and the key-backup guard.
- **Readable load errors.** Admin list and detail loads show the relay's
error message (for example "request is not authorized"), never the raw
JSON envelope.
- **Failed kicks reload the report.** A resolve that fails with
`enforcement_failed` reloads the report, so its failed action and
"Cancel & reopen" appear immediately.
- **Key backups never leave in a search.** Every admin API request URL
passes the NIP-49 key-backup guard before it is signed or sent:
community and legacy reads, mutations, the connection probe (before its
first, unsigned request) and feedback-attachment fetches. The community
and member search boxes, including the directory's Load more, refuse a
pasted `nsec` or `ncryptsec` anywhere in the input without calling the
relay.
- **`just admin-seed` for multi-community testing.** Besides the local
community, it seeds `beta.localhost:3000`, `gamma.localhost:3000` and 50
filler communities, with named members, a ban and a timeout in each
named community, a report with Kick available, and a message for the
delete preview. Rerunning it restores all of that to the starting state.
It refuses, writing nothing, while a moderation action is unfinished on
a seeded report.
- The bodyless `GET /events/{id}` preview is added to the events-URL
egress inventory. It sends no body, so it carries no key material.

## Tests

Native (Rust):
- `empty_404_and_405_read_as_complete_and_empty`,
`coded_404_carries_the_relay_code`,
`truncated_and_over_cap_bodies_are_incomplete`,
`transport_failure_has_no_status`: the structured read error tells an
older relay, a coded refusal, a cut-off body and a transport failure
apart.
- `a_401_retry_is_signed_by_the_same_keys`,
`lift_signs_the_send_and_401_retry_with_the_checked_keys`: the 401 retry
never switches keys.
- `read_urls_validate_the_explicit_host_and_encode_the_query`,
`lift_url_sends_the_page_host_not_the_active_relay`,
`list_url_carries_host_and_cursor_and_refuses_a_changed_relay`,
`lift_refuses_before_sending_on_signer_change_or_bad_host`: requests
target the explicit host and refuse to send after a relay or signer
change.

jsdom:
- `actions-pending-nav`: a pending intent survives a community change,
and only Discard drops it.
- `actions-fenced-lookup`, `fenced-load`: a late lookup for an old key
never shows under, or unlocks, the new one.
- `actions-same-ids`: the same pubkey and event id are read separately
in each community.
- `actions-community-change-drops-member`, `actions-page-host`,
`actions-success`, `actions-identity`: the form acts only in the page's
community, and confirm sends the frozen intent.
- `actions-preview-errors`, `actions-delete-link`,
`actions-staff-target`, `actions-disabled-auth`: preview errors, delete
by note link, the staff block, and community pages rendering read-only
with every action control disabled under disabled admin auth.
- `communities-directory`, `communities-unsupported`, `community-badge`,
`community-badge-disabled-auth`: the directory, older relays, and report
badges, which open community pages under disabled admin auth too.
- `restrictions-lift-signer-change`. The existing restriction tests now
run on the community page and use `admin_lift_restriction`.

Mutation checks: removing the pending-navigation guard turns
`actions-pending-nav` red, and dropping the host from the fence key
turns `actions-fenced-lookup`, `actions-same-ids` and `fenced-load` red.
Ungating restriction lifts on `canMutate` turns `actions-disabled-auth`
red.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-08 13:58:07 -04:00
Luke Tornquist
d742fc5f30 Use Silo for bundled object storage (#8023) 2026-10-07 18:02:27 -04:00
chiller
5ff83d82f8 fix(desktop): address workflow deletions at the workflow's actual owner (#2669)
Fixes #2671.

## Problem

Deleting an agent-created workflow from the desktop app appears to
succeed, but the workflow keeps running and stays in the list. Since
workflows are commonly created by agents, this makes most workflows
undeletable from the UI.

## Root Cause

`delete_workflow` builds the NIP-09 `a`-tag coordinate with the
**caller's** pubkey:

```rust
let builder = events::build_workflow_delete(&workflow_id, &current_pubkey_hex(&state)?)?;
```

kind:30620 is addressable by `(author, d-tag)`. For an agent-created
workflow, `30620:<caller>:<workflow-id>` names a record that does not
exist. The relay accepts the kind:5 (a self-addressed delete always
passes validation) and deletes nothing; the UI reports success.

Observed live: a delete of an agent-owned workflow produced
`["a","30620:<human-pubkey>:<workflow-uuid>"]` while the kind:30620
event is authored by the agent — the coordinate can never match.

## Fix

Fetch the workflow's kind:30620 event first (the same `#d` query
`update_workflow` already uses) and build the coordinate from the
event's author. Relay-side validation already authorizes the owning
human for agent-authored coordinates via `is_agent_owner`. Deleting an
unknown workflow id now surfaces `workflow not found` instead of
silently publishing an unmatchable delete.

This is the client half of the fix. The relay half is #2242: today the
relay executes a-tag deletions scoped by the delete's signer, so a
human-signed delete of an agent workflow still no-ops server-side.
Without this change, #2242 alone won't help desktop users, because the
client addresses a record that doesn't exist. Together they make delete
work end to end.

Related but deliberately not included: deleted workflows leaving their
kind:30620 event queryable (ghost list entries) is #2390, with a
relay-side fix open in #1340.

## Validation

- `cargo test` in `desktop/src-tauri`: 1617 passed, 0 failed, 14
ignored.
- `cargo clippy --all-targets` and `cargo fmt --check`: clean.
- Live repro against the hosted relay: the stock client's kind:5 for an
agent-owned workflow named the signer in the coordinate; the relay
returned `accepted: true` and the workflow remained live and kept
firing.
- With the fix: the delete event carries
`30620:<agent-pubkey>:<workflow-id>`; an owner-signed deletion of that
exact coordinate was verified to remove the workflow record.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-06 14:24:15 -07:00
Will Pfleger
001290a34d chore(release): release Buzz Desktop version 0.5.27 (#8125)
## Buzz Desktop release v0.5.27

- **Frozen main:** `af5bb0af488784c3707e99c87e0675a729711a6e`
- **Reviewed candidate:** `7a2fb3cc2d401e91e67d9819741d9025278cbec0`
- **Previous desktop release:** `desktop-v0.5.26`
- **Proposed immutable tag:** `desktop-v0.5.27`

This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.

The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-10-06 13:31:05 -04:00
tbedor-square2
403c19f673 fix(desktop): show OpenAI-compatible endpoint settings in the baked-env UI (#8117)
## Summary

`is_safe_to_reveal` (`desktop/src-tauri/src/managed_agents/env_vars.rs`)
is the single default-deny allowlist for showing baked env values. It
lists `DATABRICKS_HOST`/`DATABRICKS_MODEL`/`DATABRICKS_MODEL_FILTER` but
not their OpenAI-compatible counterparts, so a build that bakes
`OPENAI_COMPAT_BASE_URL`, `OPENAI_COMPAT_MODEL`, `OPENAI_COMPAT_API`
shows them as `••••••`.

Adds those three non-secret settings to the allowlist: an endpoint URL,
a model id, and an `auto|chat|responses` enum. **No secret is revealed:
`OPENAI_COMPAT_API_KEY` is deliberately not added and remains masked.**

### Related issue

None found.

### Testing

- New test
`baked_env_openai_compat_endpoint_is_unmasked_but_key_stays_masked`
covers the three revealed settings and asserts the API key stays masked;
`baked_env_allowlist_is_case_insensitive` extended.
- `cargo test --lib baked_env` in `desktop/src-tauri`: 11 passed. `cargo
fmt` clean.
- Not exercised in the running desktop UI.

Signed-off-by: Tom Bedor <tbedor@squareup.com>
Co-authored-by: Tom Bedor <tbedor@squareup.com>
2026-10-06 12:07:26 -05:00
Will Pfleger
9c6fa14aa0 feat(desktop): offer Claude effort levels in agent edit and create (#8059)
Claude Code agents now get a thinking-effort dropdown in the agent Edit
and Create dialogs, matching the one buzz-agent and Codex agents already
have.

## Behavior
- **Where levels come from:** Claude effort levels come from the Claude
model data for the model that will actually run after Save, resolved in
order: explicit model, then inherited default (linked definition or
global), then adapter default. Stored Claude session levels are not
used, because the stored session data does not record which model
produced them.
- **Edit:** for an agent linked to a definition, effort follows the
definition's model, which stays authoritative. Save never sends a model
for linked agents.
- **Create:** effort is offered for local agents only and is stored on
the instance. Allowed levels are recomputed on every render, so
collapsing Advanced and then switching to a model without effort (e.g.
Haiku) drops effort from the request. Non-local effort is rejected
before any create side effects.
- **Persistence:** `create_managed_agent` sets the picked effort when it
builds the record and drops stale effort env aliases. Its body runs over
any Tauri runtime, so a mock-app test drives the real create path and
reads the record back from disk. That test runs in a child process with
temp app-data and nest folders, and a test-only switch turns keychain
use off so agent keys stay inline in the temp agent file and the OS
keychain is never touched.
- **Observer store:** live session-config captures are written to the
store before the dialog refreshes; archived captures are read-only.
- **Codex:** its existing session-native effort picker is unchanged.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-06 08:58:50 -06:00
Carlos Jimenez
fcf5f044b3 fix(desktop): use object-scale-down for inline markdown images (#4686)
Fixes #4384.

A dim-less markdown image with no cached decode gets
`DEFAULT_IMAGE_RESERVE = { height: 256, width: 384 }` and
`useFixedReserveBox: true` in `resolveImageReserveBox`. The frame is
that big on first view, and the inline `<img>` uses `object-contain`
which scales UP to fill the frame — so a 46×20 shields badge renders
as a giant blurry block. The same message renders at the natural
size on the second view because `rememberDecodedImageDimensions`
populates the cache, but the inconsistency between first and
subsequent view is the bug.

Switches `object-contain` → `object-scale-down` on
`ProgressiveImage.tsx`'s shared `IMAGE_CLASS`. Both `object-contain`
and `object-scale-down` scale down to fit, but only
`object-scale-down` also refuses to scale up — a small image now sits
centered in the reserve box on first view, never upscaled. The
layout-shift guarantee for large images is preserved (they still
scale down to fit the reserve), matching the issue's stated
constraint.

Chose option 2 ("Alternatively/minimally") from the issue over
option 1 because:

- It's a one-class-name change with zero new state, zero new
  effects, and zero risk to the existing layout-shift guarantee
  that the surrounding `useFrozenImageReserve` is explicitly
  designed to preserve.
- Option 1 (collapse the frame on decode) is the issue author's
  preferred direction but requires deciding whether the layout
  shift is acceptable *shrink*-only — a direction question that
  should be confirmed with the maintainer first. The issue
  explicitly says "Happy to open a PR for option 1 if the
  direction is acceptable" — that's the sign-off step, not
  this PR.

If maintainers want the full first-view-equals-second-view
behavior, option 1 is a follow-up that builds on this base.

Not added: a regression test. The current desktop test infra
(`desktop/src/shared/ui/markdown/markdown.test.mjs` and
siblings) doesn't cover `ProgressiveImage` directly; a
DOM-style snapshot would need a new test harness. The change
is one CSS class name; the visual regression is plain to
eyeball in the screenshots CI already captures.

Verified:
- `pnpm check` (biome + file-sizes + px-text + pubkey-truncation) —
clean
- `npx tsc --noEmit` — clean

Signed-off-by: kushaim <carlossilvajimenez@gmail.com>
Co-authored-by: kushaim <carlossilvajimenez@gmail.com>
2026-10-05 11:54:35 -07:00
benthecarman
964b556a42 fix(desktop): detect Kimi Code in its default install path (#3700)
## Summary

By default on linux kimi is installed at `~/.kimi-code/bin/kimi` and
this was not detected automatically. This now fixes it by adding this
path to the detection

### Related issue

 N/A

### Testing

Before it did not appear / did not detect. Now it does :)

Signed-off-by: benthecarman <benthecarman@live.com>
2026-10-05 18:50:18 +00:00
klopez4212
5173fad660 feat(pairing): enter desktop codes on mobile (#8085)
## Summary
Enter a desktop-only, random six-digit code on mobile to pair without a
desktop confirmation click. Verification uses the encrypted session,
limits guesses to five, and never treats the QR-derived transcript as
approval. Older clients retain desktop confirmation.

Adds digit-only error shakes and native haptics, a separate biometrics
choice, and a direct community-icon arrival animation. Fixes dropped
early offers and resets completed pairing so reopening or signing out
cannot get stuck.

### Related issue
Related: #8060; no duplicate code-entry PR found.
<img width="780" height="1688" alt="01-enter-code"
src="https://github.com/user-attachments/assets/a99ced3e-4305-4ac6-a6cc-d598ced423e7"
/>
<img width="780" height="1688" alt="02-incorrect-code"
src="https://github.com/user-attachments/assets/19cdb109-1f10-40a9-a661-1d7ba89c81c1"
/>
<img width="780" height="1688" alt="03-biometrics"
src="https://github.com/user-attachments/assets/af1b2924-9ab6-4d1a-b4b1-fccfdf446404"
/>
<img width="780" height="1688" alt="04-community-loading"
src="https://github.com/user-attachments/assets/84145d82-843a-4c25-aef7-6492fc241d4b"
/>
<img width="897" height="388" alt="05-desktop-code"
src="https://github.com/user-attachments/assets/d8a2e469-7e42-466a-988f-bab6b1df85fc"
/>

### Release order
Deploy the updated pairing relay first, then desktop, then mobile. The
relay now allows eight events per connection (seven for a successful
fifth guess plus room for cancellation), with a corresponding bounded
delivery budget. Existing clients remain supported; older client
combinations keep desktop confirmation.

### Testing
- 103 mobile pairing tests, 270 core tests, 51 relay integration tests,
and the new real-relay fifth-guess flow passed.
- The real-relay regression verifies proof, decrypted identity import,
and completion after four wrong guesses. Restoring the old six-event cap
makes it fail.
- A paused-clock desktop regression confirms slow readiness cannot
extend the visible QR beyond protocol expiry. Mobile regression covers
typing before negotiation completes.
- Core/pair-relay Clippy and full native desktop Clippy/workspace tests
passed. Mobile analysis passed; broad mobile runs each hit an unrelated
failure (a gateway-script timeout and a native-menu missing-plugin
error), and both affected test files passed separately.
- Repository-wide Rust testing exhausted local disk space while
compiling unrelated packages. The final push omitted that
resource-blocked hook and avoided repeating the desktop/mobile runs
above; hosted CI remains required.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-05 13:42:39 -04:00
Will Pfleger
5a4b2d3dcc fix(desktop): show readable Claude Code model names and descriptions (#8053)
Claude Code agents' model dropdown showed raw IDs (`opus[1m]`,
`claude-fable-5-1[1m]`, `haiku`) and its effort choices showed
`low`/`xhigh`. Claude Code's ACP adapter labels config options and
choices with `name`, but Buzz read `displayName`, which no adapter
sends.

**Adapter labels and descriptions**
- `normalize_agent_models` reads `name`, keeps each model's
`description`, and uses the model option's `currentValue` as the agent's
default model (ahead of the unstable `currentModelId`).
- `parse_config_options` / `parse_option_values` read `name` for option
and choice labels, so effort shows "Low"/"Xhigh".
- The create (`PersonaModelCombobox`) and edit (`PersonaDropdownField`)
model pickers show the description as a muted second line, through both
option builders (`modelDropdownOptions` and
`buildTemplateModelDropdownOptions`).

**Generated names for full Claude model IDs**
- Claude Code's own names drop versions ("Sonnet" for
`claude-sonnet-5`). The desktop's Databricks label grammar already
parses a bare Claude ID once its wrapper is removed, so that body parse
is exposed as `generateBareModelLabel`; Databricks entry points and
outputs are unchanged.
- For the `claude` runtime, `claudeCodeModelLabel` formats full IDs and
keeps a `[1m]` suffix as "(1M context)". Short names (`opus`, `haiku`,
`opus[1m]`), unknown bracket suffixes, and IDs containing any non-ASCII
character keep the adapter's name.
- This applies to the model dropdowns in the create and edit dialogs and
the Agent defaults editor. The "Default model (…)" row appears in the
edit dialog and the Agent defaults editor; the create dialog still
requires an explicit model and shows no Default row. Model labels on
agent cards are unchanged.
- In the edit dialog, the Default row keeps the discovered label (e.g.
"Default model (Opus)") unless a global default model is set, in which
case it shows that model. When the adapter's default is its own
`default` entry (e.g. "Default (recommended)"), the row reads plain
"Default model" and keeps that entry's description.

**`models` debug command**
- The harness's readable `models` output reads option and choice labels
from `name` and the option id from `id` (falling back to `configId`), so
it no longer prints raw IDs for Claude Code. JSON output is unchanged.

| Model ID | Before | After |
|---|---|---|
| `opus[1m]` | `opus[1m]` | Opus · Opus with 1M context · … |
| `claude-fable-5-1[1m]` | `claude-fable-5-1[1m]` | Claude Fable 5.1 (1M
context) · Fable 5.1 · … |
| `claude-sonnet-5` | `claude-sonnet-5` | Claude Sonnet 5 · Sonnet 5 · …
|
| `claude-sonnet-4-6` | `claude-sonnet-4-6` | Claude Sonnet 4.6 ·
Previous Sonnet version |
| `claude-sonnet-4-6[1m]` | `claude-sonnet-4-6[1m]` | Claude Sonnet 4.6
(1M context) · Sonnet 4.6 for long sessions · … |
| `haiku` | `haiku` | Haiku · Haiku 4.5 · … |

🤖

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-02 19:28:59 +00:00
Will Pfleger
cc528dbe05 feat(agents): request summarized Claude thinking and label empty rows (#8051)
Claude Code agents on Opus showed Thinking rows that opened to nothing.
Opus returns no readable thinking unless Claude Code is asked for
summaries, and Buzz never asked. Sonnet returns readable thinking either
way.

**Request summaries from Claude Code.** `session_new_full` now adds
`_meta.claudeCode.options.extraArgs: {"thinking-display": "summarized"}`
for the Claude adapter. claude-agent-acp spreads `extraArgs` into the
CLI's arguments; 0.36.1, 0.60.0 and 0.66.0 all read the same shape. The
value is merged into the existing `_meta`, so `systemPrompt` and
`sessionTitle` are kept. `session_new_full` is the only place Buzz sends
`session/new`, so agent sessions and model discovery both get it.

**Only for a CLI that accepts the flag.** `--thinking-display` first
shipped in Claude Code 2.1.94, and older CLIs exit on unknown flags,
which would fail every session start. When the adapter is spawned, Buzz
runs `$CLAUDE_CODE_EXECUTABLE --version` once with a 5-second limit,
reading at most 256 bytes of output and killing its whole process group
on timeout, and sends the flag only on 2.1.94 or newer. The flag is left
off, and sessions start exactly as before, when:

- the CLI is older than 2.1.94;
- `CLAUDE_CODE_EXECUTABLE` is unset (the adapter then runs its own
bundled CLI, which Buzz can't inspect) or empty;
- the binary hangs, fails, or prints anything other than a clean
`major.minor.patch` version;
- `BUZZ_ACP_LAUNCH_PREFIX` is set, since the wrapper can change which
CLI the adapter actually runs.

 The result is logged once at debug level.

**Label rows that still have no text.** A Thinking row whose text is
empty or only whitespace keeps its header and shows a muted "No readable
reasoning tokens", with nothing to expand. This covers models or CLIs
that still return no readable thinking. If text arrives later in the
same row, it renders as an expandable section as before.
`ThoughtActivity` is the only component that draws Thinking rows.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-02 18:46:47 +00:00
Logan Johnson
47654215eb fix(acp): wake agents for mentions added by edits (#6131)
🤖
## Summary
Today an agent does not wake up when someone edits an existing message
to @mention it. With this PR, adding the mention in an edit is enough to
reach the agent. The agent then works on the original message, as #4741
set up.

- Agents now receive message edits by default. Before, they subscribed
only to new messages (and a few other event types).
- The desktop app puts a mention tag on an edit only for people whom
that edit newly mentions. So an agent wakes once, for the new mention.
It does not wake again when the same message is edited later. This PR
also fixes one case in the desktop app: when the original also mentioned
someone whose profile was not loaded, every mention counted as new, so a
typo fix woke the agent again. Now the recipients that the app knows
from the original always count as already notified.
- This default applies in normal mode, to channels the agent joins while
it is running, and in setup mode.
- If you give an explicit `--kinds` list, it is used without change.
- Sessions do not change in this PR. An edit uses the session that #4741
chooses: with per-thread sessions, the original message's thread; with
channel sessions, the channel.

## Details
- The change adds the edit event kind (40003) to one shared default
list. The startup rules, the relay subscription filters, and the setup
rules all use that list.
- An agent accepts an event only if both the relay subscription and its
local rules allow it. The normal listener's startup rules now come from
one function, `startup_subscription_rules`. The harness and the tests
both call it, so the tests check the rules that actually run.

### Stack
**2 of 3**, based on #4741. Next: #6132.

### Related issue
Fixes #2540.

### Testing
- `cargo test -p buzz-acp --all-targets` passes. The one test that fails
when the git setting `nostr.keyfile` is set is described in #4741.
- Admission tests run on the rules the harness installs, for the normal
listener and for setup mode. An edit that mentions the agent is
accepted. An edit that does not mention it is rejected.
- I removed kind 40003 from each of the two defaults, one at a time.
Each time, these tests failed.
- Config tests check that edits are in the default kinds for startup
channels and for channels joined later.
- Desktop regression: `@Agent @Missing User please chek` edited to
`check`, with only the agent's profile loaded, sends no new
notification. It fails on the old code.

---------

Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-02 09:50:02 -04:00
Will Pfleger
d1b7da45a2 feat(acp): add BUZZ_GIT_IDENTITY switch for agent commit identity (#8024)
🤖 This adds an operator switch for whose identity an agent's commits
carry, and removes the reason agents were clearing their git identity
env vars. Replaces #6177.

Since #7819, every agent authors and signs commits as its nostr identity
through `GIT_CONFIG_*` env vars, which beat repo and global config in
every shell the agent runs. Operators had no way to opt out of that.

**`BUZZ_GIT_IDENTITY=agent|user`.** It's read once in
`GitEnvironment::install`. Unset or `agent` keeps today's behavior.
`user` keeps only relay git auth (the credential helper and keyfile) and
installs no identity, no `git-sign-nostr` and no signing config, so git
uses the operator's own config. It also drops inherited `GIT_CONFIG_*`
entries for the agent identity and signing keys,
`author.*`/`committer.*`, and `include.path`/`includeIf.*.path`, so a
nested agent in `user` mode can't pick up its parent agent's identity.
Any other value fails startup with an error naming the variable and both
allowed values. The resolved mode is forwarded in the managed env to MCP
servers, which `buzz-agent` starts with a cleared env, so a nested
`buzz-acp` launched from an MCP shell keeps the operator's choice
instead of falling back to `agent`.

**Known limitation:** in `user` mode, an operator whose own git config
signs with `git-sign-nostr` has commits refused with a key-mismatch
error, because the agent shell's `BUZZ_PRIVATE_KEY` (needed by the
`buzz` CLI) loads ahead of `nostr.keyfile` and NIP-GS requires the
signer to exit when the loaded key doesn't match `-u`. Nothing is signed
with the wrong key. SSH and GPG signing are unaffected.

**Desktop reserves `GIT_CONFIG*`.** User env overrides are layered onto
the spawn command after the relay credential helper, so a single
`GIT_CONFIG_COUNT=0` used to silently drop it. `GIT_CONFIG` and every
`GIT_CONFIG_*` name are now reserved keys.

**`session_new_forwards_complete_git_block_without_duplicate_names` no
longer reads the runner's env.** Run inside an agent session, it picked
up the session's own inherited `GIT_CONFIG_*` and failed. Agents worked
around that by unsetting `GIT_CONFIG_COUNT`, and any commit made in that
shell fell back to the human's git identity and signing key. The test
now hides the runner's `GIT_CONFIG*` and `BUZZ_GIT_IDENTITY` under
`ENV_LOCK` and restores them afterwards.

**Agent instructions.** `base_prompt.md` and the nest `AGENTS.md` now
say the runtime sets the agent's commit identity and signing, and that
agents shouldn't override them with `user.*` config, `-c user.*`,
`--author` or another signing key. Trailers are added only when the
repository or the person the agent works for requires them. If a
repository requires a different author, the agent tells the operator,
who can switch to `BUZZ_GIT_IDENTITY=user`, instead of overriding the
identity. The nest `AGENTS.md` scopes this to agents Buzz runs through
its stock `buzz-acp` harness, and notes that custom harnesses get only
relay git auth and control their own attribution. `NEST_AGENTS_VERSION`
goes from 5 to 6.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 17:04:33 -04:00
Logan Johnson
04b040ef6c test(desktop): pin mention undo and status expiry smoke behavior (#7592)
🤖

## Summary

Make two desktop smoke tests reliably check existing behavior: manually
mentioning an agent after turning off automatic mentions, and editing a
status while the saved status expires. This changes only tests, not
production UI behavior, helpers, or configuration.

- **Manual mention recovery:** the old test expected a manual mention to
restore the agent's automatic-mention badge. The test now checks that
the badge disappears after undo and stays absent when the user manually
mentions the agent, while the sent message goes to exactly that agent.
Choosing **Automatically mention** explicitly restores the badge and
keeps the agent's mention in the composer after sending.
- **Status expiry while editing:** the old test gave the saved status
two seconds to expire, racing the dialog setup. The test now controls
the page clock, seeds a five-minute status, and establishes an open
dialog with unsaved edits before advancing past expiry. The existing
checks still require the saved status to disappear from the sidebar
without losing the draft, show a future-duration warning, disable Save,
and allow recovery by choosing **This week**.

The two-file repair is unchanged from its previous version. This branch
includes current main, including the MinIO startup fix in
https://github.com/block/buzz/pull/7599; that infrastructure change is
not part of this PR's test-only diff.

### Related issue

none found

### Testing

Both specs exercise the UI through the existing test fixtures. Fresh
normal CI for `ae6ff228f86f42d5203050c99bcb99cdce3a4626` is pending;
historical test results and review of the earlier version do not
validate this combined tree. Local dependency installation was blocked
by network policy in the authoring environment, so no new local pass is
claimed.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-01 15:05:35 -04:00
Will Pfleger
83aab8cb5c feat(desktop): remove communities locally and stop their agent pairs (#7997)
🤖 Leave contacts the relay before it removes anything locally: it checks
the relay's `/info` for NIP-43 membership support, then waits for the
relay to accept a signed leave request. When a relay has been torn down,
that check fails, Leave throws, and the community stays in the rail.
Removing a community also left its relay's managed agent pairs running,
and they kept reconnecting to it until the app was quit.

**Remove from this device**
- A new community action, **Remove from this device**, sits under
**Leave community** in the profile menu's community actions. It never
contacts the relay. A confirm dialog explains that relay membership
isn't revoked.
- `useCommunityNavigationTransitions` splits the old `removeCommunity`
into `removeCommunityFromDevice` (local-only cleanup and navigation to a
fallback community or the discovery screen) and
`leaveAndRemoveCommunity` (calls `leaveCommunity`, then runs the same
cleanup). Leave's flow hasn't changed.
- When Leave fails, the error now adds: "If the community no longer
exists, use Remove from this device." The error shown when community
discovery state can't be saved no longer says "Membership was removed",
so its wording fits both actions.

**Agent pairs on a removed relay**
- Once the local removal completes, the shared cleanup stops every live
agent pair on that relay, including manually started ones. It matches
relays with `canonicalRelayUrl`. Stopping is best-effort: failures are
logged and the removal still completes. Pairs on other relays aren't
touched, and nothing is stopped if another community still uses the same
relay.
- Both reconcile call sites (`useManagedAgentRuntimeReconciliation` and
`bootstrapManagedAgentRuntimePairs`) now go through
`reconcileConfiguredManagedAgentRuntimes`. A reconcile that is already
running in Rust can't be cancelled, so after it resolves, this helper
stops any pair it started whose relay is no longer in the saved
community list.
- Launch restore runs in the background after `apply_workspace` and can
finish after its community is removed. `apply_workspace` takes a restore
ticket (the current `launch_restore_generation` in `AppState`) before
handing its lock to the restore task. Removing the active community
calls the new `invalidate_launch_restore` command, which bumps that
generation while holding the runtime transition lock.
`restore_managed_agents_on_launch` re-checks its ticket under the same
lock and spawns nothing if it changed. If restore takes the lock first,
its pairs are registered before invalidation returns, and the removal
stop then finds them. Removal never changes `relay_url_override`: the
outgoing relay stays applied until the next `apply_workspace`, exactly
as on `main`, so in-flight work never falls back to the default relay.
Invalidation never takes `workspace_apply_lock`, which restore holds
while waiting on the transition lock, so lock order is unchanged.
- With no relay applied, backend readers fall back to the default relay,
the same state as launch before the first `apply_workspace`. A normal
quit and relaunch doesn't bring a pair back on a removed relay: restore
targets the applied workspace, and the legacy per-record relay pin is
ignored.

**Tests**
- A `community-rail` e2e case makes the mocked relay's membership check
fail, the way an unreachable relay does. It checks that Leave shows the
error and keeps the community, and that Remove from this device then
removes it, switches to the fallback, stops the agent pair on the
removed relay, and leaves the pair on the other relay running.
- Unit tests in `managedAgentRelayCleanup.test.mjs` cover relay
matching, a stop or listing failure not throwing, and the reconcile
fence stopping a pair that started on a relay removed mid-flight.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 18:43:21 +00:00
Matthew Boston
036215636f Allow following channel messages before their first reply (#7692)
## Summary

Channel messages only offered Follow thread after receiving a reply. Add
Follow/Unfollow to delivered messages immediately, so users can opt into
future replies. Keep the same follow state when broadcast replies gain
replies of their own.

Threads you authored, participated in, or explicitly followed offer
Unfollow. A mention alone leaves Follow available. These controls apply
to channel timelines; DM notification behavior remains unchanged.

### Related issue

None found in existing issues or PRs.

### Testing

- Browser regressions cover keyboard follow/unfollow, persistence after
reload, first-reply unread and notification delivery, suppression after
unfollow, pending-message exclusion, broadcast reply transitions after
revealing buffered arrivals, and immediate unfollow for your own
messages.
- Additional regressions cover following after a mention and the absence
of Follow/Unfollow controls in DM timelines.
- Desktop unit tests, typecheck, lint, and production build pass.
- Browser notification routing is covered; native OS banner appearance
was not tested.

### Screenshots

| Before | After |
|--------|--------|
| <img width="979" height="301" alt="before"
src="https://github.com/user-attachments/assets/1a739187-5bc3-4e23-a334-723ca69672f5"
/> | <img width="979" height="337" alt="after"
src="https://github.com/user-attachments/assets/57df4ff9-bac4-4b9d-a39f-5664e0f9ece0"
/> |

---------

Signed-off-by: Matthew Boston <mboston@squareup.com>
2026-10-01 10:15:45 -06:00
klopez4212
0ee2ad7fbf Fix rounded macOS icon on older Macs (#8018)
Older macOS versions display Buzz’s opaque icon as an oversized square.
Bake rounded corners and transparent padding into the macOS `.icns`,
preserving the original bee artwork and leaving other platforms
unchanged.

Generation uses Hermit-pinned uv, Python 3.14.3, and Pillow 12.2.0.
`scripts/macos-icon.sh --check` regenerates temporarily and compares all
decoded representations; macOS CI runs it before packaging. Pixel
reproducibility is verified independently of Apple’s ICNS container
encoding.

Validated native AppKit preview, all 10 representations, fresh-cache
generation, one-pixel/missing-representation drift rejection, workflow
lint, and pre-push checks. Full local `just ci` is running. Packaged
Dock checks on older/current macOS remain outstanding.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-01 17:00:57 +01:00
Will Pfleger
bd0896f5c0 fix(desktop): read channel metadata and member lists from writer after writes (#7999)
🤖 This fixes two problems with the same cause. Since #3268, the relay
can answer an unpinned `/query` from the Aurora read replica, as long as
the replica has proved it's within `BUZZ_REPLICA_READ_MAX_AGE_MS` (1000
ms) of the writer. That's fine for display reads, but it doesn't
guarantee a client sees its own write.

**Problem 1: channel creation (Wes's report).** Right after creating a
channel, the desktop read back the channel's kind:39000 metadata by
`#d`. If that read landed on a replica that hadn't replayed the event
yet, it failed with `channel created but metadata not yet available`.
The channel existed, but the chosen template (canvas, agents) was never
applied.

**Problem 2: member lists.** After any membership change, the
member-list refetch could also hit a lagging replica. The relay returned
the list from before the change, and the desktop cached it as fresh for
five minutes (`CHANNEL_MEMBERS_STALE_TIME_MS`). Someone just removed
could still show as a member, or someone just added could be missing.

## Fix 1: metadata read-backs go to the writer

`channel_metadata_filter` is a shared builder that adds `"consistency":
"strong"`, so the relay serves the read from the writer. It follows the
same pattern as the canvas head reads. All four metadata read-backs that
follow a write use it: `create_channel`, `update_channel`,
`fetch_starter_channel_metadata` (its retry and fallback are unchanged)
and the open-DM refetch in `dms.rs`. A unit test fails if the flag is
removed from the builder.

The template's agent-add step also reads channel members (kind:39002)
right after create. `get_channel_members` takes an opt-in
`read_your_writes` flag that sends `"consistency": "strong"`. Only
`createChannelManagedAgents` sets it on that path, so the agent-add step
sees the creator's membership.

## Fix 2: a 31-second writer-read window after membership changes

Every membership write the desktop makes calls
`noteChannelMembershipChange` for each channel it changed:

- the native write wrappers `createChannel`, `openDm`, `joinChannel`,
`leaveChannel`, `addChannelMembers`, `removeChannelMember` and
`changeChannelMemberRole`
- the Bestie conversation open
- huddle agent add and remove, for both the huddle channel and its
parent
- `ensure_starter_channels` and `sync_agents_to_active_huddle`. Both
return every channel they created, joined or changed in
`changed_channel_ids`, including ones that landed before a later step
failed. The original error text is rethrown unchanged.
- live join, leave, removed and `admin_kick` system messages, and
member-added and member-removed notifications
- a report kick resolved from the Admin Console, once the relay confirms
it succeeded and only when the report's channel is in the active
community. The kick goes through the admin HTTP API, not a channel
event. Ban, timeout and their lifts only change `community_bans`, not
channel membership, so they need no record.

For 31 seconds after a change, measured on `performance.now()`,
`getChannelMembers` for that channel sends `readYourWrites`. Why 31
seconds: the relay caps its replica staleness budget at
`FENCE_STALENESS` (30 s), so once the window closes, no supported
configuration can serve the pre-change list. The constant's comment ties
it to `FENCE_STALENESS`. One app-level listener cancels any roster fetch
already in flight and refetches. When it registers, it also refreshes
every channel whose window is still open. All other member-list reads
still use the replica.

**Community switch.** The window map is cleared when the community
changes. Each awaited write captures a generation through
`beginChannelMembershipWrite()` before its first native call, and the
reset advances it. So a write that settles after a community switch
records nothing in the new community. Live events don't need this fence,
because the reset disconnects the relay client first and frames from the
old connection are dropped.

## Coverage

- A Rust test drives `ensure_starter_channels` against a stub relay that
accepts the creates but never serves metadata, and checks that both
created starter channels are still reported.
- Desktop tests cover the writer-read window, the Admin Console kick,
and writes that settle after a community switch.
- The desktop E2E test bridge (`desktop/src/testing/e2eBridge.ts`)
mirrors the strong reads and `changed_channel_ids`, so E2E runs exercise
the fixed behavior.

This doesn't address the smaller race in the relay's per-pod
accessible-channels cache, and it doesn't change mobile.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 11:32:16 -04:00
Will Pfleger
f001ebbb6e feat(desktop): add Admin Console Actions tab for direct staff actions (#7904)
Builds on #7883 (merged).

Adds an **Actions** tab to the Admin Console so relay staff can ban,
time out, or delete directly through the #7883 endpoints (`POST
/members/{pubkey}/ban`, `POST /members/{pubkey}/timeout`, `POST
/events/{id}/delete`), with no report required.

## Design

- **Frozen intent.** Review freezes the whole request (origin, relay,
signer, community, action, target, reason, duration, `requestId`).
Confirm and Retry resend it unchanged, so a manual retry reuses the same
`requestId` and stays idempotent on the relay. The intent is cleared
only on a definitive non-409 4xx (`preserveRequestIdOnError`). A 409 or
a 202 pending keeps it, except `409 request_id_conflict`, which is final
for that id and drops the intent so no futile Retry is offered. A 202
pending renders as a neutral notice, not an error.
- **Bound to signer and relay.** The intent carries `expectedPubkey` and
`expectedRelay`. Native `admin_direct_action` refuses before sending any
request if either is blank or no longer matches
(`assert_expected_signer` / `assert_expected_relay_scope`). The tab is
keyed on pubkey + origin, so an identity or relay switch remounts it and
drops any frozen intent.
- **Survives tab switches.** `ActionsTab` stays mounted while the panel
is open and is only hidden on other tabs, with the same pubkey + origin
key, so a pending, ambiguous or in-flight action (and its `requestId`)
is still there on return.
- **Nothing-sent refusals.** Every native refusal before the first
request leaves (malformed host, changed relay or signer, bad target or
duration, the key-backup guard on Reason, a missing HTTP client, the
first request build, the signing-key lookup) returns
`AdminMutationError` with `notSent: true`; the tab drops the intent and
offers no Retry. The request build for the retry after a 401 stays
ambiguous, because the first request already went out. The changed-relay
refusal says "active relay changed since the action was confirmed;
nothing was sent".
- **Disabled auth.** With `canMutate=false`, Review, Confirm and every
field are disabled.
- **Community host.** The host is prefilled from the active relay URL by
`communityHostFromRelayUrl` (URL host, lowercased, default port and
trailing dot stripped, matching the relay's `normalize_host`) and shown
read-only as "Community: <host>" with a Change link. It follows the
active community until the operator edits it; with no relay URL the
input shows directly. Review derives the host from the same
`getRelayWsUrl()` value it freezes into `expectedRelay`, so the two
can't disagree. The host is validated natively
(`validate_community_host`) and sent only as the `communityHost` query,
never in the body. The body is `{requestId, reason?, expirationSecs?}`,
built by the pure `direct_action_request`; only a timeout takes a
duration.
- **Member picker.** Ban and timeout pick the member the way
`AddMemberDialog` does: `useUserSearchQuery` name search,
`parsePubkeyInput` for a pasted npub or hex in any case, and a selected
chip. The target is always sent as lowercase hex. Name search is a
profile search on the active relay, not a member list, so it runs only
when the host is the connected community; for another host the field
takes npub or hex only, and the hint stays visible even with a key
selected. Editing the host to a different community clears the pick and
the search query, so returning to the original host doesn't bring it
back; pressing Change alone keeps it. Each named result shows a short
npub so same-name profiles can be told apart, the selected chip opens
the full key, and the confirm step shows "Name (npub1…abcd)" plus the
full npub (`PubKey variant="full"`). A search result's name is frozen
for display only; a pasted key uses `useUserProfileQuery` on the active
community, otherwise the short npub. Result buttons follow the disabled
state. Changing the member or host drops a frozen intent. Delete keeps
its hex event id (native `Hex64` route type, shared with pubkeys). A
query that starts with `nsec1` or `ncryptsec1` in any case is never
searched; the picker shows "That's a secret key. Never paste it here."
instead.
- **Errors.** `target_is_staff` and `request_id_conflict` get dedicated
copy; other codes fall back to the relay message (`adminErrorCode`). An
empty 404/405 from a relay without these routes reads "This relay
doesn't support direct actions yet."; an uncoded 404/405 with a body
keeps its own text. Success shows a toast.
- **Key-backup guard.** Every admin JSON mutation body (the three direct
routes and the existing report-resolve routes) passes
`egress_guard::assert_no_key_backup_bytes` in `helpers.rs` before
signing. The egress inventory points at that guarded boundary.
- **One signer snapshot.** `admin_direct_action` reads `signing_keys()`
once, checks `expectedPubkey` against it, and signs the request and any
401 retry with that same `Keys`.
- **Serialized Review.** Review and its inputs lock while the relay URL
resolves; a late Review cannot replace a frozen or submitting intent.
- **Reason audience.** Before confirmation the tab states that the
reason is delivered to the affected user, and for channel deletes posted
publicly in the room tombstone (same copy as the Reports tab).
Confirmation shows the frozen reason.

## Fix → test → mutation

| Fix | Test | Mutation that turns it red |
|---|---|---|
| Manual retry reuses `requestId` | `actions-retry` | mint a new id per
attempt |
| Intent preserved across retryable outcomes | `actions-retry`,
`actions-errors` | always clear the frozen intent |
| Confirmation tied to signer + relay | Rust
`direct_action_refuses_a_changed_signer_or_relay` | skip the signer
check |
| Identity/origin change drops intent | `actions-identity` | drop the
tab key |
| Disabled auth keeps Submit off | `actions-disabled-auth` | remove the
`canMutate` gate |
| Key-backup bytes never leave (`ncryptsec1…` / `NCRYPTSEC1…`, zero
requests; ordinary reason sends) | Rust
`direct_action_refuses_key_backup_reason_before_any_request` | remove
the guard call → panics (request sent) |
| Checked signer is the signing key | Rust
`direct_action_signs_only_with_the_validated_snapshot` (identity swapped
after the signer snapshot is taken, before validation) | re-read
`signing_keys()` for signing → signed with the swapped key |
| Late Review cannot replace the intent | `actions-review-race` (Review,
Review, Confirm ambiguous, Retry → one `requestId`) | drop the Review
lock |
| Pending is a neutral notice | `actions-pending-neutral` | store
pending in the error state → `pending notice must render` |
| `request_id_conflict` offers no Retry | `actions-request-id-conflict`
| keep the intent on every 409 → `request_id_conflict`, `no Retry for a
spent request id` |
| Reason audience copy + frozen reason | `actions-audience` | drop the
audience copy; drop the frozen reason |
| Host prefilled read-only, Change reveals the input |
`actions-host-prefill` | drop the prefill → `host must be prefilled from
the active relay` |
| Name result sent as hex and named on confirm | `actions-member-search`
| — |
| npub and uppercase hex sent as lowercase hex | `actions-member-keys` |
drop the direct key result → `no direct result for npub1…` |
| Another community turns name search off with a hint |
`actions-foreign-host` | — |
| A host change clears the pick, even after returning to the first host;
hint stays with a key selected | `actions-host-change-drops-member` |
hide the pick by host instead of clearing it → `returning to the first
host must not restore the old pick` |
| Same-name results differ by key; confirm shows the chosen full npub
and sends that key | `actions-same-name` | drop the full npub from
confirm → `confirm must show the full npub` |
| Results can't be selected once auth is disabled |
`actions-results-disabled` | — |
| Secret key never reaches search, warning shown | `actions-secret-key`
| remove the check → `a secret key reached search_users` |
| Pending action survives a tab round-trip, Retry reuses `requestId` |
`actions-tab-roundtrip-pending` | render the tab conditionally →
`pending notice survives` |
| Ambiguous failure survives a tab round-trip |
`actions-tab-roundtrip-ambiguous` | render conditionally → fails |
| In-flight failure on another tab shows on return |
`actions-tab-roundtrip-inflight` | render conditionally → fails |
| Pre-send refusal drops the intent, no Retry | `actions-not-sent`, Rust
`direct_action_marks_a_pre_send_refusal_not_sent` | ignore `notSent` →
`intent dropped; no Retry` |
| Key-backup refusal is nothing-sent; Review editable again | Rust
`direct_action_refuses_key_backup_reason_before_any_request`,
`actions-backup-reason-not-sent` | drop `not_sent` on the guard → panics
at the `notSent` assert; stub `notSent: false` → `no Retry after a
pre-send refusal` |
| Only an empty 404/405 reads as an old relay | `actions-old-relay` |
drop the empty-body condition → `404 admin API error: Not Found` |

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 18:53:13 -04:00
Will Pfleger
965e1997b5 fix(agents): stop built-in prompts from teaching sleep polling (#7992)
An @mention wakes an agent only once its running tool call returns, so
agents that loop `sleep` + `buzz messages get` (or poll CI) delay every
reply that arrives in the meantime. Buzz's own prompts taught that loop.

- `crates/buzz-acp/src/base_prompt.md`: replace "No push notifications —
poll with …" with a rule to never use a tool call (`sleep`, `--watch`)
to wait on CI, teammates, or other external events; hand off and end the
turn. `buzz messages get --since` stays as a one-shot catch-up. "keep
working until you publish the verified result" now reads "the result,
handoff, …" so agents stop reading it as wait-for-green-CI.
- `desktop/src-tauri/src/managed_agents/nest_skill.md`: the "Polling
Pattern" sleep-and-repeat loop becomes a "Catching Up" one-shot
`--since` read.

🤖 Opened by Duncan.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 18:28:54 +00:00
Alex Rosenzweig
95b018c871 feat(acp): wrap workers at the subprocess launch boundary (#7985)
## Summary

Replacing `BUZZ_ACP_AGENT_COMMAND` with a sandbox launcher hides the
real adapter from Buzz: for example, Goose loses its default `acp`
argument. Add optional `BUZZ_ACP_LAUNCH_PREFIX`, a JSON argument array
applied at the shared subprocess boundary, after normal worker
configuration.

On Unix, every spawn runs `prefix... worker args...` without shell
interpolation. Worker identity, environment setup, stdio and existing
process cleanup are preserved. Invalid or unavailable prefixes fail
launch without falling back to the worker; unset keeps direct launch on
every platform. Configured prefixes fail closed on non-Unix because
per-worker process-tree cleanup is unavailable. Desktop reserves the key
against saved user-environment overrides, and spawn failures name the
executable.

### Related issue

Prerequisite for [Buzz-App
#415](https://github.com/block/buzz-app/pull/415). One commit directly
on `main`, independent of #7942–#7944. No duplicate launch-prefix PR
found. Policy enforcement, verified launcher staging, protected paths
and the supporting runtime pin remain Buzz-App/plugin work.

### Testing

- Original head `9ae9d82f8`: complete ACP suite (990 passed, three
existing ignored), shipped local-task test, real-Goose probe, and full
repository-wide `just ci` passed.
- Review fixes: five launch tests and 44 Desktop environment-filter
tests pass locally, covering the real production platform gate, save
rejection, both merge layers, case variants, and executable error
context.
- Subprocess tests cover direct/wrapped argument defaults, Pi skills,
Hermes/Codex environment, adapter identity, repeated spawns and
invalid/missing prefix refusal.
- Shipped `buzz-acp run` entrypoint completes a task through the wrapper
and deterministic ACP peer.
- Real Goose 1.52.0: direct and wrapped `auth-methods` return identical
results; the wrapper receives `goose acp` with initially empty worker
args.
- Independent agent review: no blocking findings.
- Required push checks passed on the updated head. The existing native
Windows CI job now explicitly runs the platform-contract tests. Its
result is pending; the non-Unix refusal branch was not exercised on this
macOS host.

Repeated-spawn coverage is not a full lazy-pool wake/crash test.
Protected Goose in the Buzz-App UI and human acceptance remain pending.

To try the hook after building `buzz-acp`, use an installed Goose path:

```sh
BUZZ_ACP_LAUNCH_PREFIX='["/usr/bin/env"]' target/debug/buzz-acp auth-methods \
  --agent-command /absolute/path/to/goose --agent-args '' --json
```

Expect the same authentication methods as without the prefix. Changing
the prefix to `["/missing-launcher"]` must fail. Draft pending human
testing.

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
2026-09-30 10:52:29 -05:00
Shawn Hank
2664d14316 Add Kimi Code's default install path to managed-agent binary discovery (#5997)
## Summary

Buzz Desktop fails to detect a freshly-installed Kimi Code CLI, even
though `kimi --version` works fine in the terminal. Root cause is in
binary discovery, not in Kimi's install.

## Problem

After running Kimi Code's installer, Buzz Desktop's harness list doesn't
pick up the `kimi` binary — it stays undetected until the user manually
sources their shell config.

## Repro

1. Install Kimi Code: `curl ... code.kimi.com/kimi-code/install.sh |
bash` — installs to `~/.kimi-code/bin`, and the installer appends a PATH
export only to `~/.zshrc`.
2. Open a fresh terminal or restart Buzz Desktop without `source
~/.zshrc`.
3. Buzz Desktop does not detect `kimi`, even though it's on disk and
executable.

## Root cause

`find_via_login_shell()` in
`desktop/src-tauri/src/managed_agents/discovery.rs` resolves binaries by
spawning `zsh -l -c 'command -v <bin>'`. A login shell sources
`~/.zprofile`/`~/.zshenv`, but zsh only sources `~/.zshrc` for
*interactive* shells — so a PATH export that lives only in `~/.zshrc`
(which is where Kimi's installer puts it) is invisible to this probe.
`~/.kimi-code/bin` is also absent from the static fallback list in
`common_binary_paths()`.

This is the same root-cause class already handled for `nvm` in this file
(see the `find_nvm_default_bin` comment) — Kimi just doesn't have an
equivalent fallback yet.

## Fix

Add `~/.kimi-code/bin` to the Unix branch of `common_binary_paths()`,
alongside the existing `.volta/bin` / `.bun/bin` / `.asdf/shims`
entries.

## Scope note

This PR only fixes detection (finding the binary). It does not address
that Kimi shows a "Ready" badge regardless of login status — that's a
separate, larger gap affecting all five preset harnesses (Kimi,
OpenCode, Amp, Hermes, OpenClaw), tracked separately.

## Test plan

- [x] `just ci` passes
- [x] Manual: fresh Kimi install per repro steps above, confirm Buzz
Desktop shows it without sourcing `~/.zshrc`

Signed-off-by: Shawn Hank <shawn@shawnhank.com>
2026-09-29 19:36:01 -07:00
Diem Nguyen
12dbb114aa test(desktop): fix flaky forum empty-draft replacement e2e (#7981)
## Summary
The `replacement=` cases (`add` and `publish`) of `forum source visit …:
A→B→A retains source draft` in `forum-agent-invitation.spec.ts` are
flaky. They fail at line 449 because the source draft still reads
`@RemoteScout hello`.

Cause: Playwright's `fill("")` on a contenteditable sets a DOM selection
and then presses `Delete`. ProseMirror handles `Delete` with its own
selection, which sometimes hasn't synced from the DOM yet. When that
happens the key press does nothing, `onUpdate` never fires, and the
clear is never recorded. I confirmed this with temporary logging:
failing runs have no authored-content update after the fill. Non-empty
`fill` uses `insertText`, which goes through ProseMirror's DOM input
path, so it isn't affected.

Fix (test only):
- Clear the draft with `ControlOrMeta+a` followed by `Delete`, so
ProseMirror owns the selection.
- Assert the replacement text before navigating away.

### Related issue
None found. The spec hasn't changed since #7125. This test failed in 10
of the last 40 CI runs of `Desktop Smoke E2E (2)`, for example #3700.

### Testing
- `--repeat-each=100 --retries=0 --workers=4` on the two affected cases:
  - Before the fix: 14 of 200 runs failed at `:449`.
  - After the fix: 200 of 200 passed.
- Full `forum-agent-invitation.spec.ts`: 19 passed.
- Pre-push hooks passed (desktop typecheck and desktop tests).

Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
2026-09-29 15:59:30 -07:00
Will Pfleger
fccc07ebe3 chore(release): release Buzz Desktop version 0.5.26 (#7980)
## Buzz Desktop release v0.5.26

- **Frozen main:** `7ebf1beb44139264e9c5c7fee6970429ff66f934`
- **Reviewed candidate:** `2b4b138dc5cf2d9cc1a0ceb21d9063ff56fe8bf4`
- **Previous desktop release:** `desktop-v0.5.25`
- **Proposed immutable tag:** `desktop-v0.5.26`

This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.

The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-09-29 18:31:44 -04:00
Jobi Armstrong
7ebf1beb44 fix(desktop): correct Grok Build setup guide URL (#4846)
## Problem

The **Grok Build** harness preset points its "Setup guide" link at
`https://build.x.ai/docs`, which is unreachable — the hostname has no
DNS record at all, so the button leads to a dead page rather than a 404.

```console
$ getent hosts build.x.ai
(no output — no record)

$ getent hosts docs.x.ai
2606:4700::6812:1350  docs.x.ai

$ curl -sS https://build.x.ai/docs
curl: (6) Could not resolve host: build.x.ai
```

This is user-facing: the link sits behind the **Setup guide** button in
the harness picker, so anyone trying to connect Grok Build hits it while
looking for install instructions.

## Fix

Point the preset at xAI's live Grok Build documentation.

```diff
-        install_instructions_url: "https://build.x.ai/docs",
+        install_instructions_url: "https://docs.x.ai/build/overview",
```

The preset's `command` and `args` are **unchanged and remain correct** —
Grok Build ships native ACP over stdio, which `grok agent stdio`
exposes:

```console
$ grok agent --help
Commands:
  stdio     Run the agent over stdio
  headless  Run the agent headlessly over the Grok WebSocket relay
  ...
```

## Testing

- `just ci` passes locally (fmt, clippy, desktop lint, Tauri clippy,
unit tests, mobile tests).
- Verified `https://docs.x.ai/build/overview` resolves and returns
`200`.
- Verified the harness itself still connects: installed Grok Build
0.2.118 via `irm https://x.ai/cli/install.ps1 | iex` and confirmed `grok
agent stdio` matches the preset's invocation.

No UI changes — this is a string constant, so no screenshots apply.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: armstrongj2001 <110222228+armstrongj2001@users.noreply.github.com>
Co-authored-by: Diem Nguyen <ngthuydiem@users.noreply.github.com>
2026-09-29 13:58:25 -07:00
crgallego
c96553eb13 fix(desktop): resolve unlisted project channel requests (#7619)
## Summary
- resolve project channel-creation approvals through the authoritative
home-channel selector
- permit owner-visible unlisted project homes when their channel and
repository binding match
- add regression coverage for the unlisted authoritative-home path

## Validation
- `just ci`
- focused project-home regression tests (10/10)
- full Desktop suite (6,451/6,451)
- `pnpm check`
- `pnpm build`

## Manual verification
After merging into a Desktop build, approve the pending LBX Copywriting
workroom request from the Litbox OS project.

Signed-off-by: crgallego <chris.cokerconstruction@gmail.com>
Co-authored-by: crgallego <chris.cokerconstruction@gmail.com>
2026-09-28 15:46:07 -07:00
Luke Tornquist
6918dd3a83 Schedule the deletion drain safely (#7827)
## Why

The deletion engine already exposes a one-shot `buzz-admin deletions
drain` command, but operators have no safe way to schedule it. A generic
workload wrapper could expose arbitrary commands or inherit relay
credentials, which would add a second control plane and widen the blast
radius.

## What

This PR adds an opt-in Kubernetes CronJob for the typed deletion drain
command. It is stacked on #7818, which adds owner-request admission.

## How

The chart keeps the job disabled by default. The CronJob uses `Forbid`,
zero Kubernetes retries, bounded runtime and history, and only the
database, Redis, and object-store settings required by the executor.
PostgreSQL leases, checkpoints, and retry timing remain the sole durable
retry authority.

The closed `_operator-jobs.tpl` helper centralizes safe CronJob
mechanics without creating an arbitrary command or environment registry.
This leaves the touched system simpler than a general job framework
would.

## Risk

The deployment blast radius is low because the job is disabled by
default. When enabled, it can run destructive deletion work that the
database has already approved, so the chart limits concurrency, retries,
credentials, and execution time.

## Testing

Rendered every `deploy/charts/buzz/ci/*.yaml` fixture with `helm
template`; all passed at `e68cb753bb6673aa1baa60046969bdb2d4738b62`.

Ran the full independent Desktop, Tauri, and web lanes; all passed at
`e68cb753bb6673aa1baa60046969bdb2d4738b62`. Flutter package resolution
made no progress within three minutes, so the mobile lane is not claimed
green.

## Bigger picture

A later stack layer must inventory and approve admitted owner requests.
Until then, this scheduler cannot make a new owner request destructive.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-28 18:23:42 -04:00
Logan Johnson
f1e50be410 test(desktop): wait for channel head refresh before paging thread summary test (#7955)
🤖

## Summary
Main's Desktop Smoke E2E (4) job fails on `scroll-history.spec.ts`
("thread summary badge survives a retained older-history prepend"). This
change fixes the test's timing. It does not change app code.

When a channel opens, the app starts a live subscription. When that
subscription is ready, the app fetches the newest page again, and that
fetch replaces the whole loaded history. This test scrolled up before
that refresh happened. On slower CI runners, the refresh came after the
older page loaded but before it showed on screen. The older rows went
away, the reader stayed at the top, and no more pages loaded. All three
attempts failed that way.

The test now calls `waitForMockChannelHeadReady` before it scrolls, like
the other pagination tests in this file already do. That helper waits
for the live subscription and its refresh to finish.

### Related issue
Main CI failure at `15d44dcc`
(https://github.com/block/buzz/actions/runs/36470599751/job/109092076235).
The same failure was reported on #7827.

### Testing
- Added temporary logs to see the order of events. Locally, the refresh
ran after the older page had loaded: the loaded history went from 2
pages back to 1. The test passed only because it checked before the next
render. With the wait, the refresh runs first and nothing replaces the
loaded pages later.
- `playwright test --project=smoke tests/e2e/scroll-history.spec.ts`: 18
passed. This test with `--repeat-each=8`: 8 passed.

### Follow-up (not in this PR)
The app has the same race for real users. If someone scrolls up before
the live subscription is ready, or during a reconnect, the refresh can
drop the older history they loaded. If they are at the top of the list,
paging stops until they scroll down and back up.

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-28 14:44:16 -06:00